270 lines
14 KiB
Markdown
270 lines
14 KiB
Markdown
# 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`):
|
|
|
|
```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.ts` — `MigrationState`, schema v2, preserve +
|
|
`updateMigration`.
|
|
- `frontend/src/main.ts` — `appState:getMigration` / `appState:setMigration`
|
|
handlers.
|
|
- `frontend/src/preload.ts` — `ao.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.
|