diff --git a/docs/plans/2026-06-26-import-offer.md b/docs/plans/2026-06-26-import-offer.md new file mode 100644 index 000000000..078e0fa59 --- /dev/null +++ b/docs/plans/2026-06-26-import-offer.md @@ -0,0 +1,364 @@ +# Dashboard-Surfaced Legacy Import Offer — Implementation Plan + +> **For agentic workers:** implement task-by-task; each task ends with a green test + a commit. Steps use `- [ ]`. + +**Goal:** Replace the first-boot CLI import prompt with a daemon API (`GET`/`POST /api/v1/import`) and a dashboard banner. **Scope: projects + per-project settings only** (no orchestrator sessions, no transcript relocation), a faithful port of `aoagents/ReverbCode` PR #320. + +**Architecture:** `ao start` becomes headless. The `legacyimport` engine is simplified to import projects only. A new `service/importer` wraps it with a detection probe (`Status`) and a trigger (`Run`) that writes through the daemon's shared store. An HTTP controller exposes both; the Electron renderer polls status and shows an `ImportOffer` banner. Because startup is now headless, the Electron main process also gains always-on discovery of a daemon it didn't spawn. + +**Tech Stack:** Go (chi, sqlc, code-first OpenAPI via `cmd/genspec`), React + @tanstack/react-query + openapi-fetch, Electron, vitest. + +## Global Constraints + +- Module path: `github.com/aoagents/agent-orchestrator/backend`. +- **No em dashes** anywhere (prose, comments, copy). Use `.`/`,`/`(...)`. +- `openapi.yaml` and `frontend/src/api/schema.ts` are **generated** (never hand-edit); change the Go reflection source then run `npm run api:spec && npm run api:ts`. +- All app state under `~/.ao` only (already enforced; don't regress). +- Branch: `ao/agent-orchestrator-3/import-offer` (sibling of `…/root`). PR target `main` on `AgentWrapper/agent-orchestrator`. +- **Reference:** this repo sits exactly at PR #320's base, so the change set lines up 1:1 with that PR. Where a step says "PR-verbatim," copy the PR's content for that file. +- Commit immediately after each task (AO worktrees can be force-removed). + +## File Structure + +| File | Status | Responsibility | +| --- | --- | --- | +| `backend/internal/legacyimport/orchestrator.go` (+`_test.go`) | delete | orchestrator-session mapping/import (out of scope) | +| `backend/internal/legacyimport/claude.go` (+`_test.go`) | delete | Claude transcript relocation (out of scope) | +| `backend/internal/storage/sqlite/store/session_import_store.go` (+`_test.go`) | delete | `ImportSession` (only used by orchestrator import) | +| `backend/internal/legacyimport/importer.go` | modify | trim `Store`/`Options`/`Report`; drop orchestrator loop; add `quote()` | +| `backend/internal/legacyimport/config.go` | modify | `Repo` -> `*yaml.Node`; tolerate `*yaml.TypeError` | +| `backend/internal/legacyimport/paths.go` | modify | drop `defaultClaudeProjectsDir` + `projectSessionsDir` | +| `backend/internal/legacyimport/{importer,config,project}_test.go` | modify | projects-only assertions | +| `backend/internal/cli/import.go` | modify | drop orchestrator/transcript copy + summary lines | +| `backend/internal/service/importer/importer.go` (+`_test.go`) | create | `Status`/`Run` over the daemon store | +| `backend/internal/httpd/controllers/imports.go` (+`_test.go`) | create | `GET`/`POST /import`, 501 on nil svc | +| `backend/internal/httpd/controllers/dto.go` | modify | `ImportStatusResponse`, `ImportRunResponse` | +| `backend/internal/httpd/apispec/specgen/build.go` | modify | `import` tag + ops + schemaNames | +| `backend/internal/httpd/api.go` | modify | `APIDeps.Import`, `API.imports`, wire + Register | +| `backend/internal/daemon/daemon.go` | modify | `Import: importsvc.New(...)` | +| `backend/internal/cli/start.go` | modify | delete `maybeFirstBootImport` + 2 imports | +| `backend/internal/httpd/apispec/openapi.yaml` | regenerate | — | +| `frontend/src/api/schema.ts` | regenerate | — | +| `frontend/src/shared/daemon-discovery.ts` (+`.test.ts`) | modify | `shouldAdoptDiscoveredPort` | +| `frontend/src/main.ts` | modify | external daemon discovery loop | +| `frontend/src/renderer/hooks/useImportStatus.ts` | create | `useImportStatus`/`useRunImport` | +| `frontend/src/renderer/components/ImportOffer.tsx` (+`.test.tsx`) | create | the banner | +| `frontend/src/renderer/components/SessionsBoard.tsx` | modify | render `` | + +--- + +## Task 1: Simplify the import engine to projects-only + +**Files:** Delete `legacyimport/orchestrator.go`, `orchestrator_test.go`, `claude.go`, `claude_test.go`, `storage/sqlite/store/session_import_store.go`, `session_import_store_test.go`. Modify `legacyimport/importer.go`, `config.go`, `paths.go`, and their tests; `cli/import.go`. + +**Interfaces:** +- Produces: trimmed `legacyimport.Store` (`GetProject`, `UpsertProject` only), `legacyimport.Options{Root, DryRun, Now, RepoOriginURL}`, `legacyimport.Report{DryRun, ProjectsImported, ProjectsSkipped, Notes}`. **Tasks 2 and 3 depend on this `Report` shape.** + +- [ ] **Step 1: Delete the orchestrator/transcript files.** + +```bash +git rm backend/internal/legacyimport/orchestrator.go backend/internal/legacyimport/orchestrator_test.go \ + backend/internal/legacyimport/claude.go backend/internal/legacyimport/claude_test.go \ + backend/internal/storage/sqlite/store/session_import_store.go \ + backend/internal/storage/sqlite/store/session_import_store_test.go +``` + +- [ ] **Step 2: Trim `legacyimport/importer.go`.** + - `Store` interface: keep only `GetProject` and `UpsertProject` (drop `GetSession`, `ImportSession`). + - `Options`: drop `DataDir` and `ClaudeProjectsDir`; keep `Root`, `DryRun`, `Now`, `RepoOriginURL`. + - `Report`: reduce to `{DryRun bool, ProjectsImported int, ProjectsSkipped int, Notes []string}`. + - In `Run`, delete the orchestrator block (the `sessionsDir`/`readOrchestratorMapping`/`switch mapping.status` that follows `importProject`), so the loop body ends after `importProject`. + - Delete the `importOrchestrator` function. + - Add the `quote()` helper used by the project-id note: + +```go +// quote wraps s in double quotes for note messages, rendering an empty string as +// "?" so a missing value is still legible. +func quote(s string) string { + if s == "" { + return `"?"` + } + return `"` + s + `"` +} +``` + +- [ ] **Step 3: `legacyimport/config.go` parsing-robustness fix (PR-bundled, improves project import).** + - Add `"errors"` import. + - Change the project config's `Repo string \`yaml:"repo"\`` field to `Repo *yaml.Node \`yaml:"repo"\`` with a comment that it is captured but never consumed (origin is re-resolved from the repo path). + - In `loadLegacyConfig`, when `yaml.Unmarshal` errors, keep the partial decode on a `*yaml.TypeError` instead of failing the whole registry: + +```go + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + } +``` + +- [ ] **Step 4: `legacyimport/paths.go`.** Delete `defaultClaudeProjectsDir` and `projectSessionsDir`; drop the now-unused `"strings"` import; update the package doc to "maps the legacy project registry and per-project settings." +- [ ] **Step 5: Update tests.** Rewrite `importer_test.go` (drop `sessions` from `fakeStore`, drop orchestrator/transcript assertions, `writeLegacyRoot`/`runOpts` take only `root`), `config_test.go`, and `project_test.go` (add the `nonNilNode()` helper returning a populated `*yaml.Node`) per the PR. +- [ ] **Step 6: `cli/import.go`.** Update the command `Short`/`Long` to say "projects" only, the confirm prompt to "Import projects from %s?", and delete the `Orchestrators:`/`Transcripts:` lines from `writeImportSummary`. Remove the `DataDir` field from the `legacyimport.Options{...}` literal it builds. +- [ ] **Step 7:** `cd backend && go build ./... && go test ./internal/legacyimport/... ./internal/cli/... ./internal/storage/sqlite/...` → expect PASS. +- [ ] **Step 8:** Commit: `refactor(legacyimport): scope import to projects + settings only` + +--- + +## Task 2: Import service (`service/importer`) + +**Files:** Create `backend/internal/service/importer/importer.go`, `…/importer_test.go` + +**Interfaces:** +- Consumes: trimmed `legacyimport.Store`, `legacyimport.Run`, `legacyimport.HasLegacyData`, `legacyimport.DefaultLegacyRootDir`, `legacyimport.Report`, `legacyimport.Options{Root}`; `domain.ProjectRecord`. +- Produces: `importer.Status{Available bool; LegacyRoot string}`, `importer.Service` (`Status(ctx)`, `Run(ctx)`), `importer.Deps{Store, Root}`, `importer.New(Deps) *Manager`. **Controller (Task 3) and daemon (Task 5) depend on these exact names.** + +- [ ] **Step 1: Write `importer.go`** (projects-only: no `DataDir`): + +```go +// Package importer is the controller-facing service for the legacy-AO import. +// It wraps the internal/legacyimport engine with the two operations the +// dashboard needs: a detection probe ("is a legacy install available?") and a +// trigger that runs the import through the live daemon's store, so the daemon +// stays the sole writer. The engine is reused verbatim; this package adds no +// import logic of its own, only the daemon-side detection and the store wiring. +package importer + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" +) + +// Store is the storage slice the import service needs: the legacy importer's +// write surface plus a project listing for the "already imported" check. +// *sqlite.Store satisfies it, so the daemon passes its single shared store and +// the import runs through the same write path as every other mutation. +type Store interface { + legacyimport.Store + ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) +} + +// Status reports whether a legacy AO install is available to import. Available +// is true only when legacy data is present AND the rewrite database holds no +// projects yet (the first-boot condition); a populated database is assumed +// already imported (or started fresh on purpose), so the offer is not surfaced. +type Status struct { + Available bool `json:"available"` + LegacyRoot string `json:"legacyRoot"` +} + +// Service is the controller-facing import contract. +type Service interface { + Status(ctx context.Context) (Status, error) + Run(ctx context.Context) (legacyimport.Report, error) +} + +// Deps bundles the import service's dependencies. +type Deps struct { + // Store is the rewrite's durable store (the daemon's shared *sqlite.Store). + Store Store + // Root overrides the legacy AO root to read. Empty -> the default. + Root string +} + +// Manager implements Service over the daemon's store and config. +type Manager struct { + store Store + root string +} + +var _ Service = (*Manager)(nil) + +// New constructs the import service. An empty Root falls back to the default +// legacy root so callers that don't override it get the standard location. +func New(deps Deps) *Manager { + root := deps.Root + if root == "" { + root = legacyimport.DefaultLegacyRootDir() + } + return &Manager{store: deps.Store, root: root} +} + +// Status reports import availability without touching legacy or rewrite data +// beyond a project count. It never errors on a missing legacy store; that is +// simply "not available". +func (m *Manager) Status(ctx context.Context) (Status, error) { + st := Status{LegacyRoot: m.root} + if !legacyimport.HasLegacyData(m.root) { + return st, nil + } + projects, err := m.store.ListProjects(ctx) + if err != nil { + return Status{}, err + } + st.Available = len(projects) == 0 + return st, nil +} + +// Run executes the import through the daemon's store. It is idempotent: the +// engine skips rows that already exist, so a re-run (or a run against a +// partially-populated database) is safe and never overwrites. Legacy files are +// never modified. +func (m *Manager) Run(ctx context.Context) (legacyimport.Report, error) { + return legacyimport.Run(ctx, m.store, legacyimport.Options{Root: m.root}) +} +``` + +- [ ] **Step 2: Write `importer_test.go`** PR-verbatim: `fakeStore` implements only `GetProject`/`UpsertProject`/`ListProjects` (the trimmed `Store` needs nothing else); tests `TestStatus_NoLegacyData`, `TestStatus_LegacyPresentEmptyDB`, `TestStatus_AlreadyPopulated`, `TestStatus_ListError`, `TestRun_ImportsThenStatusFlipsUnavailable`, `TestNew_DefaultsRoot`. +- [ ] **Step 3:** `cd backend && go test ./internal/service/importer/...` → expect PASS. +- [ ] **Step 4:** Commit: `feat(importer): import service over daemon store (status + run)` + +--- + +## Task 3: HTTP controller + DTOs + +**Files:** Create `backend/internal/httpd/controllers/imports.go`, `…/imports_test.go`; Modify `…/controllers/dto.go` + +**Interfaces:** +- Consumes: `importsvc.Status`, `legacyimport.Report`, `apispec.NotImplemented`, `envelope.WriteJSON`/`WriteError`, `httpd.NewRouterWithControl(cfg, log, termMgr, APIDeps, ControlDeps)`. +- Produces: `controllers.ImportService`, `controllers.ImportController{Svc}`, `controllers.ImportStatusResponse`, `controllers.ImportRunResponse`. **api.go (Task 5) and specgen (Task 4) depend on these.** + +- [ ] **Step 1:** Add to `dto.go` (add the `legacyimport` import): + +```go +// ImportStatusResponse is the body of GET /api/v1/import: whether a legacy AO +// install is available to import, and the root the daemon would read from. +type ImportStatusResponse struct { + Available bool `json:"available"` + LegacyRoot string `json:"legacyRoot"` +} + +// ImportRunResponse is the body of POST /api/v1/import: the structured outcome +// of the import run (counts + notes), reused verbatim from the import engine. +type ImportRunResponse struct { + Report legacyimport.Report `json:"report"` +} +``` + +- [ ] **Step 2:** Create `imports.go` PR-verbatim (`ImportService` interface, `ImportController`, `Register`, `status`, `run`; nil `Svc` answers `apispec.NotImplemented`). +- [ ] **Step 3:** Create `imports_test.go` PR-verbatim (`fakeImportService`, `newImportTestServer` using `httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Import: svc}, httpd.ControlDeps{})`, tests `TestImportAPI_Status/StatusError/Run/RunError`). The `doRequest` helper already exists in the `controllers_test` package. +- [ ] **Step 4:** `go build ./...` fails until Task 5 wires `APIDeps.Import` (expected). Parse-check now: `gofmt -l internal/httpd/controllers/imports.go`. Test gate runs at end of Task 5. +- [ ] **Step 5:** Commit: `feat(httpd): import controller + DTOs (GET/POST /api/v1/import)` + +--- + +## Task 4: OpenAPI spec generation + +**Files:** Modify `backend/internal/httpd/apispec/specgen/build.go`; regenerate `openapi.yaml` + `frontend/src/api/schema.ts` + +- [ ] **Step 1:** In `build.go` add, PR-verbatim: the `import` tag (in `Tags`), the schemaName mappings (`ControllersImportStatusResponse`->`ImportStatusResponse`, `ControllersImportRunResponse`->`ImportRunResponse`, `LegacyimportReport`->`ImportReport`), the `importOperations()` func (2 ops), and `ops = append(ops, importOperations()...)`. +- [ ] **Step 2:** Regenerate: `npm run api:spec` then `npm run api:ts`. +- [ ] **Step 3:** Inspect the diff. The generated `ImportReport` schema must be the projects-only 4-field shape (`dryRun`, `projectsImported`, `projectsSkipped`, `notes`), matching the PR's `schema.ts` verbatim. If orchestrator fields appear, Task 1's `Report` trim was incomplete; fix it before continuing. +- [ ] **Step 4:** `cd backend && go test ./internal/httpd/apispec/...` (route<->spec parity) → expect PASS. +- [ ] **Step 5:** Commit: `feat(apispec): describe /api/v1/import; regenerate openapi + schema.ts` + +--- + +## Task 5: Wire controller into API + daemon + +**Files:** Modify `backend/internal/httpd/api.go`, `backend/internal/daemon/daemon.go` + +- [ ] **Step 1:** `api.go` edits: + - `APIDeps`: add `Import controllers.ImportService` (after `NotificationStream`). + - `API` struct: add `imports *controllers.ImportController` (after `notifications`). + - `NewAPI`: add `imports: &controllers.ImportController{Svc: deps.Import},` (after the `notifications:` line). + - `Register` timeout group: add `a.imports.Register(r)` (after `a.notifications.Register(r)`). +- [ ] **Step 2:** `daemon.go` edits: + - Add import: `importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"`. + - In the `httpd.APIDeps{...}` literal, add: `Import: importsvc.New(importsvc.Deps{Store: store}),` (projects-only: **no** `DataDir`). +- [ ] **Step 3:** `cd backend && go build ./... && go test ./internal/httpd/... ./internal/service/importer/...` → expect PASS (gate for Task 3's tests too). +- [ ] **Step 4:** Commit: `feat(daemon): mount import service on the API` + +--- + +## Task 6: Make `ao start` headless + +**Files:** Modify `backend/internal/cli/start.go` + +- [ ] **Step 1:** Delete the `maybeFirstBootImport` method and its call. Replace the call + the comment above it with: + +```go + // `ao start` is headless: it only launches the daemon. Detecting a legacy AO + // install and offering to import it is the dashboard's job (it polls + // GET /api/v1/import and POSTs to run the import through the live daemon). + // `ao import` remains for explicit offline imports. +``` + +- [ ] **Step 2:** Remove now-unused imports `…/internal/legacyimport` and `…/internal/storage/sqlite`. Keep `config`. Leave `cli/import.go`, `confirm`, `stdinIsInteractive`, `writeImportSummary` untouched. +- [ ] **Step 3:** `cd backend && go build ./... && go test ./internal/cli/...` → expect PASS. +- [ ] **Step 4:** Commit: `refactor(cli): ao start no longer prompts; import moves to the dashboard` + +--- + +## Task 7: External daemon discovery (Electron main) + +**Files:** Modify `frontend/src/shared/daemon-discovery.ts`, `…/daemon-discovery.test.ts`, `frontend/src/main.ts` + +**Interfaces:** Produces `shouldAdoptDiscoveredPort(current: DaemonStatus, discoveredPort: number): boolean`. + +- [ ] **Step 1 (TDD):** Add the `shouldAdoptDiscoveredPort` describe block to `daemon-discovery.test.ts` (PR-verbatim). Run `npm --prefix frontend run test -- daemon-discovery` → expect FAIL (not exported). +- [ ] **Step 2:** Add `shouldAdoptDiscoveredPort` + the `DaemonStatus` type import to `daemon-discovery.ts` (PR-verbatim). Re-run → expect PASS. +- [ ] **Step 3:** In `main.ts`: import `shouldAdoptDiscoveredPort`, add `EXTERNAL_DISCOVERY_POLL_MS = 1_000`, `externalDiscoveryTimer`, `discoverExternalDaemonOnce()`, `startExternalDaemonDiscovery()` (PR-verbatim); call `startExternalDaemonDiscovery()` in `app.whenReady().then(...)` and clear the timer in `before-quit`. Skip the prettier-only reflows the PR carries. +- [ ] **Step 4:** `npm --prefix frontend run typecheck` → expect PASS. +- [ ] **Step 5:** Commit: `feat(electron): discover an externally-started daemon from running.json` + +--- + +## Task 8: Import status hooks + +**Files:** Create `frontend/src/renderer/hooks/useImportStatus.ts` + +**Interfaces:** Consumes `apiClient`/`apiErrorMessage` (`renderer/lib/api-client`), `workspaceQueryKey` (`renderer/hooks/useWorkspaceQuery`, value `["workspaces"]`). Produces `useImportStatus()`, `useRunImport()`, `importStatusQueryKey`, types `ImportStatus`/`ImportReport`. + +- [ ] **Step 1:** Create the file PR-verbatim (`ImportReport` TS type = `{projectsImported, projectsSkipped, notes?}`). `useImportStatus` polls every 30s with `throwOnError: false`; `useRunImport` invalidates `importStatusQueryKey` + `workspaceQueryKey` on success. +- [ ] **Step 2:** `npm --prefix frontend run typecheck` → expect PASS. +- [ ] **Step 3:** Commit: `feat(renderer): useImportStatus / useRunImport hooks` + +--- + +## Task 9: ImportOffer banner + +**Files:** Create `frontend/src/renderer/components/ImportOffer.tsx`, `…/ImportOffer.test.tsx` + +- [ ] **Step 1 (TDD):** Create `ImportOffer.test.tsx` PR-verbatim (mocks `../lib/api-client`; asserts offer-shown/hidden/accept/decline/error). Run `npm --prefix frontend run test -- ImportOffer` → expect FAIL (component missing). +- [ ] **Step 2:** Create `ImportOffer.tsx` PR-verbatim. Heading "Import projects from your earlier AO?"; body copy "Importing brings in your projects. Your old files are never modified, and you can do this later instead." (projects-only, no orchestrator mention). Built from `ui/button` (`primary`/`ghost`, size `sm`). Re-run → expect PASS. +- [ ] **Step 3:** Commit: `feat(renderer): ImportOffer dashboard banner` + +--- + +## Task 10: Render the banner on the board + +**Files:** Modify `frontend/src/renderer/components/SessionsBoard.tsx` + +- [ ] **Step 1:** Add `import { ImportOffer } from "./ImportOffer";` and, directly under the `` line, insert: + +```tsx + {/* First-run legacy-AO import opt-in. Renders only when the daemon + reports an importable install, and only on the top-level board. */} + {!projectId && } +``` + +- [ ] **Step 2:** `npm --prefix frontend run typecheck && npm --prefix frontend run test` → expect PASS. +- [ ] **Step 3:** Commit: `feat(renderer): surface ImportOffer on the dashboard board` + +--- + +## Task 11: Full verification + +- [ ] `cd backend && go build ./... && go test -race ./...` → green. +- [ ] `golangci-lint run` on touched packages → clean. +- [ ] `npm --prefix frontend run typecheck && npm --prefix frontend run test` → green. +- [ ] Confirm `git status` shows **no** uncommitted drift in `openapi.yaml`/`schema.ts` after a fresh `npm run api:spec && npm run api:ts`. +- [ ] **Full build** (per the build-verification rule; rollup tree-shaking can hide missing emits): run the frontend production build. +- [ ] `ao preview` the dashboard against a daemon pointed at a seeded `~/.agent-orchestrator` legacy root with an empty rewrite DB; verify the banner appears, Import imports the projects + retires the banner, Not now dismisses. + +--- + +## Self-Review + +**Spec coverage:** ✅ engine simplified to projects-only (T1); `service/importer` status+run (T2); `GET`/`POST /api/v1/import` + 501 (T3); OpenAPI + schema.ts regen (T4); api/daemon wiring (T5); headless `ao start` (T6); external daemon discovery (T7); `useImportStatus`/`useRunImport` (T8); `ImportOffer` + board render (T9/T10). Matches PR #320 1:1. + +**Placeholder scan:** none — every new file has full source or is "PR-verbatim" for an existing PR file; every edit names the symbol and location. + +**Type consistency:** `importer.Deps{Store, Root}` (T2) ↔ daemon `importsvc.New(importsvc.Deps{Store: store})` (T5) ✅ (no `DataDir` anywhere). `legacyimport.Report` 4-field shape (T1) ↔ `ImportRunResponse` (T3) ↔ generated `ImportReport` (T4) ↔ `ImportReport` TS type (T8) ✅. `controllers.ImportService` (T3) ↔ `APIDeps.Import` (T5) ✅. `importStatusQueryKey`/`workspaceQueryKey` (T8) ↔ `ImportOffer` (T9) ✅. `Status{Available, LegacyRoot}` consistent across service/controller/DTO/schema ✅. + +**Cross-task coupling to watch:** (a) Task 3's controller test only goes green after Task 5 wires `APIDeps.Import` (gate = end of Task 5); (b) Task 4 must run after `dto.go` (Task 3) exists so the reflector sees the new types; (c) Task 4 Step 3 is the guard that Task 1's `Report` trim actually took. diff --git a/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md b/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md new file mode 100644 index 000000000..bff6c8ba8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md @@ -0,0 +1,260 @@ +# 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..*` | `frontend/src/preload.ts:73` | +| IPC handler pattern | `ipcMain.handle(":", …)` 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: ``. 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.