agent-orchestrator/docs/superpowers/specs/2026-06-26-legacy-migration...

14 KiB

Dashboard Legacy-Migration Popup + app-state.json Marker: Design

Status: ready for plan. Grounded against upstream/main @ 514946fd8
(feat(cli): ao start fetches + opens the desktop app … (#2201)) on 2026-06-26.
Every "current state" claim carries a file:line reference.

Builds on the marker concept from the ao start bootstrapper spec
(docs/ao-start-bootstrapper-and-npm-deprecation.md, §5). Deferred Settings
work is tracked in AgentWrapper/agent-orchestrator#2205.


0. Goal

ao start no longer runs the legacy import (it now fetches+opens the desktop
app; the daemon-spawn path and maybeFirstBootImport are gone). The spec's §6.4
left an open decision: where does the legacy first-boot import go? This design
answers it: the desktop app offers the import on launch via a popup, gated on a
persisted migration marker in ~/.ao/app-state.json.
If the user hasn't
migrated (and legacy data exists), the app prompts. The import runs through the
app-owned daemon and is idempotent; legacy files are never modified, so a failed
or declined import loses nothing.


1. Ground truth (what the code is today)

Fact Value Source
Marker file ~/.ao/app-state.json, app is sole writer, written every launch frontend/src/main/app-state.ts; spec §5, invariant 3
Marker fields today schemaVersion, appPath, version, installedAt, lastReconciledAt, installSource frontend/src/main/app-state.ts AppStateMarker
Marker write call site app.whenReady() before createWindow() frontend/src/main.ts:859 (inside whenReady, :868)
Go reader of marker read-only, ignores unknown JSON fields, does NOT gate on schemaVersion backend/internal/cli/start.go appState struct (~:38)
Import engine internal/legacyimport (projects + per-project settings + orchestrator + transcripts) + ao import CLI present from #314
Import daemon API does not exist on main (no service/importer, no httpd/controllers/imports.go) verified
Daemon ownership the app spawns + owns the daemon frontend/src/main.ts startDaemon
HTTP client (renderer) apiClient over the daemon loopback API frontend/src/renderer/lib/api-client.ts:81
IPC bridge contextBridge.exposeInMainWorld("ao", api); renderer calls window.ao.<ns>.* frontend/src/preload.ts:73
IPC handler pattern ipcMain.handle("<ns>:<action>", …) in main frontend/src/main.ts:773-796
Dashboard route _shell.index.tsx (the board) frontend/src/renderer/routes/
Global Settings page none — only per-project _shell.projects.$projectId_.settings.tsx verified

2. Decisions locked

  1. Approach A: daemon import API (detect + run) + app-side marker. The
    renderer uses apiClient; the app stores only the decision in
    app-state.json. (User-approved.)
  2. App is the sole writer of app-state.json (invariant 3); the daemon is
    the sole writer of the DB
    . The import runs through the daemon; the app
    records the marker.
  3. Import scope = projects + per-project settings only (no orchestrator
    sessions, no transcripts). The daemon API is the projects-only engine from the
    import-offer plan (docs/plans/2026-06-26-import-offer.md).
  4. Popup actions: Proceed (run), Skip (re-prompt next launch),
    Don't Migrate (red; permanently declines). Small print points to a future
    Settings redo path.
  5. Settings "Migration" section is deferred to AgentWrapper/agent-orchestrator#2205.
    v1 ships the popup only. Until #2205 lands, declined is effectively
    permanent, so v1 copy must NOT promise a working Settings path (see §6).

3. Scope

In scope:

  • Backend: the projects-only import daemon API: GET /api/v1/import (availability)
    and POST /api/v1/import (run), plus service/importer, DTOs, OpenAPI regen.
    (= the import-offer plan, with the status-semantics tweak in §5.2.)
  • App marker: add a migration block to app-state.json (schemaVersion → 2),
    preserved across launches; an IPC getter/setter.
  • Renderer: a launch-time MigrationPopup gated on (marker not terminal) AND
    (daemon reports legacy data available); the three-action UX; failure
    reassurance.

Out of scope (deferred / separate):

  • Global Settings page + the Migration section / redo entry point → #2205.
  • The engine simplification details themselves live in the import-offer plan; this
    design consumes that API, it does not re-specify the engine internals.
  • Track B (signing/auto-update) and anything in the bootstrapper spec's out-of-scope.

4. Invariants (load-bearing)

  1. Filesystem/DB is the source of truth; the marker is a hint. A completed
    marker is never trusted to mean the rows exist; re-running is always safe
    (idempotent engine). (Mirrors bootstrapper invariant 2.)
  2. App is the sole writer of app-state.json. The renderer never writes it
    directly; it goes through the main process over IPC.
  3. The import never deletes or modifies legacy files. This is what makes a
    failed/declined import lossless, and it is the promise the failure copy makes.
  4. The import is idempotent. Existing rows are skipped, so Skip-then-Proceed,
    double-clicks, and Settings re-runs never duplicate or clobber.

5. Backend: the import daemon API

5.1 Surface (from the import-offer plan)

  • GET /api/v1/import{ available: bool, legacyRoot: string }.
  • POST /api/v1/import{ report: { dryRun, projectsImported, projectsSkipped, notes? } }.
  • internal/service/importer wraps legacyimport (projects-only); wired into
    daemon.go as Import: importsvc.New(importsvc.Deps{Store: store}).
  • A nil service answers OpenAPI-backed 501. OpenAPI + schema.ts regenerated.

5.2 Status semantics (the one change vs the import-offer plan)

The import-offer plan computed available = HasLegacyData(root) && len(projects)==0
(the empty-DB heuristic). Here the marker governs whether to prompt, so the DB
heuristic is redundant and can be wrong (a user who added projects but never
imported legacy still has legacy data). Define:

available = legacyimport.HasLegacyData(root)

The "already decided / don't nag" logic moves entirely to the app marker;
idempotency keeps a re-run safe regardless of DB contents.


6. App marker: migration block in app-state.json

Bump SCHEMA_VERSION to 2 and extend AppStateMarker
(frontend/src/main/app-state.ts):

export type MigrationStatus = "pending" | "completed" | "declined" | "failed";

export interface MigrationState {
	status: MigrationStatus; // absent file => treated as "pending"
	lastAttemptAt?: string; // last Proceed attempt (success or failure)
	completedAt?: string; // set when status -> completed
	report?: { projectsImported: number; projectsSkipped: number };
	error?: string; // last failure message (status === "failed")
}

export interface AppStateMarker {
	schemaVersion: number;
	appPath: string;
	version: string;
	installedAt: string;
	lastReconciledAt: string;
	installSource: string;
	migration?: MigrationState; // new; preserved across launches
}
  • Preservation: the launch-time writeAppStateMarker must carry an existing
    migration block through untouched (the same way it preserves installedAt /
    installSource). It never sets migration itself.
  • Setter: a new updateMigration(stateDir, partial, now) does an atomic
    read-modify-write (temp + rename, same as writeAppStateMarker) so the marker
    is updated without clobbering the launch-written fields.
  • Go reader: unchanged. start.go's appState ignores the unknown
    migration field and does not gate on schemaVersion, so bumping to 2 is safe
    (verified). Adding a mirrored Migration field there is optional and not needed
    for v1.

Terminal states (never auto-prompt): completed, declined. Prompting states:
pending, failed.


7. Renderer: detection, gate, popup

7.1 IPC additions

  • main: ipcMain.handle("appState:getMigration", …) → returns MigrationState
    (or {status:"pending"} when absent); ipcMain.handle("appState:setMigration", (_e, partial) => updateMigration(...)).
  • preload: extend the ao bridge with
    appState: { getMigration, setMigration }.

7.2 Gate (where the popup decides to show)

A useMigrationOffer() hook, consumed on the dashboard (_shell.index.tsx):

  1. read window.ao.appState.getMigration(),
  2. if status ∈ {completed, declined} → render nothing,
  3. else GET /api/v1/import; show the popup only when available === true
    (a 501 / unreachable daemon resolves to "not available", never an error).

Skip sets an in-memory dismissed flag for the session (no marker write) so the
popup returns next launch. This matches "re-prompt until complete" for Skip while
keeping Don't Migrate permanent.

7.3 Popup actions / data flow

Action Effect
Proceed POST /api/v1/import. On success: setMigration({status:"completed", completedAt, report}), then invalidate the workspace query so projects appear. On failure: setMigration({status:"failed", lastAttemptAt, error}) and show the reassurance inline.
Skip session-only dismiss; no marker write; re-prompts next launch.
Don't Migrate (red) setMigration({status:"declined", lastAttemptAt}); never auto-prompts again.

7.4 Copy

  • Heading: "Import projects from your earlier AO?"
  • Body: names the legacy root; "Importing brings in your projects. Your old files
    are never modified, and you can do this later." (projects-only; no orchestrator
    mention
    .)
  • Failure: "Migration failed: <error>. Your legacy projects are untouched
    (nothing is ever deleted). You can retry." with a Retry that re-POSTs.
  • Small print: a neutral "You can run this again later." v1 must not promise a
    Settings location
    (Settings is deferred to #2205); the Settings wording lands
    with that issue.

8. Components / files

Backend (per the import-offer plan, projects-only; status tweak §5.2):
internal/service/importer/*, internal/httpd/controllers/imports.go,
dto.go, apispec/specgen/build.go, api.go, daemon.go, regenerated
openapi.yaml + frontend/src/api/schema.ts.

App / renderer (new in this design):

  • frontend/src/main/app-state.tsMigrationState, schema v2, preserve +
    updateMigration.
  • frontend/src/main.tsappState:getMigration / appState:setMigration
    handlers.
  • frontend/src/preload.tsao.appState bridge methods.
  • frontend/src/renderer/hooks/useMigrationOffer.ts — gate (IPC marker + daemon
    GET).
  • frontend/src/renderer/components/MigrationPopup.tsx — the three-action dialog
    (built from components/ui/*).
  • frontend/src/renderer/routes/_shell.index.tsx — mount the popup on the board.

9. Error handling

  • Daemon unreachable / 501 on GET → "not available" → no popup (never blocks).
  • POST failure → failed marker + lossless-reassurance copy + Retry.
  • Corrupt/missing marker → treated as pending (self-healing, like the existing
    reader).
  • Atomic temp+rename for every marker write → a concurrent ao start reader never
    sees a partial file.

10. Testing

  • app-state.ts: v2 round-trip; launch write preserves an existing
    migration block; updateMigration merges without clobbering appPath/etc.;
    corrupt file → pending.
  • Backend: import service status (available = HasLegacyData only) + run +
    idempotency; controller GET/POST + 501; route↔spec parity. (From the plan.)
  • useMigrationOffer / MigrationPopup: popup shows only when not-terminal +
    available; Proceed success → completed + workspace invalidated + popup retires;
    Proceed failure → reassurance + Retry; Skip → no marker write; Don't Migrate →
    declined and stays hidden.

11. Open decisions

  1. Settings redo path → deferred to #2205 (locked: not in v1).
  2. Mirror migration into the Go appState struct? Not needed for v1; add
    only if a CLI surface ever needs to read migration status.
  3. Where exactly to mount the popup — board (_shell.index.tsx) vs the shell
    layout (_shell.tsx) so it shows on any route. Default: board, since that is
    the first-run landing surface; revisit if it should be shell-wide.