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 afile:linereference.Builds on the marker concept from the
ao startbootstrapper 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
- Approach A: daemon import API (detect + run) + app-side marker. The
renderer usesapiClient; the app stores only the decision in
app-state.json. (User-approved.) - 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. - 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). - Popup actions: Proceed (run), Skip (re-prompt next launch),
Don't Migrate (red; permanently declines). Small print points to a future
Settings redo path. - Settings "Migration" section is deferred to AgentWrapper/agent-orchestrator#2205.
v1 ships the popup only. Until #2205 lands,declinedis 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)
andPOST /api/v1/import(run), plusservice/importer, DTOs, OpenAPI regen.
(= the import-offer plan, with the status-semantics tweak in §5.2.) - App marker: add a
migrationblock toapp-state.json(schemaVersion→ 2),
preserved across launches; an IPC getter/setter. - Renderer: a launch-time
MigrationPopupgated 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)
- 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.) - App is the sole writer of
app-state.json. The renderer never writes it
directly; it goes through the main process over IPC. - 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. - 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/importerwrapslegacyimport(projects-only); wired into
daemon.goasImport: importsvc.New(importsvc.Deps{Store: store}).- A nil service answers OpenAPI-backed
501. OpenAPI +schema.tsregenerated.
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
writeAppStateMarkermust carry an existing
migrationblock through untouched (the same way it preservesinstalledAt/
installSource). It never setsmigrationitself. - Setter: a new
updateMigration(stateDir, partial, now)does an atomic
read-modify-write (temp + rename, same aswriteAppStateMarker) so the marker
is updated without clobbering the launch-written fields. - Go reader: unchanged.
start.go'sappStateignores the unknown
migrationfield and does not gate onschemaVersion, so bumping to 2 is safe
(verified). Adding a mirroredMigrationfield 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", …)→ returnsMigrationState
(or{status:"pending"}when absent);ipcMain.handle("appState:setMigration", (_e, partial) => updateMigration(...)). - preload: extend the
aobridge with
appState: { getMigration, setMigration }.
7.2 Gate (where the popup decides to show)
A useMigrationOffer() hook, consumed on the dashboard (_shell.index.tsx):
- read
window.ao.appState.getMigration(), - if
status ∈ {completed, declined}→ render nothing, - else
GET /api/v1/import; show the popup only whenavailable === 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.ts—MigrationState, schema v2, preserve +
updateMigration.frontend/src/main.ts—appState:getMigration/appState:setMigration
handlers.frontend/src/preload.ts—ao.appStatebridge methods.frontend/src/renderer/hooks/useMigrationOffer.ts— gate (IPC marker + daemon
GET).frontend/src/renderer/components/MigrationPopup.tsx— the three-action dialog
(built fromcomponents/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). POSTfailure →failedmarker + 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 startreader never
sees a partial file.
10. Testing
app-state.ts: v2 round-trip; launch write preserves an existing
migrationblock;updateMigrationmerges without clobberingappPath/etc.;
corrupt file →pending.- Backend: import service status (
available=HasLegacyDataonly) + 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 →
declinedand stays hidden.
11. Open decisions
- Settings redo path → deferred to #2205 (locked: not in v1).
- Mirror
migrationinto the GoappStatestruct? Not needed for v1; add
only if a CLI surface ever needs to read migration status. - 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.