24 KiB
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.yamlandfrontend/src/api/schema.tsare generated (never hand-edit); change the Go reflection source then runnpm run api:spec && npm run api:ts.- All app state under
~/.aoonly (already enforced; don't regress). - Branch:
ao/agent-orchestrator-3/import-offer(sibling of…/root). PR targetmainonAgentWrapper/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 <ImportOffer/> |
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,UpsertProjectonly),legacyimport.Options{Root, DryRun, Now, RepoOriginURL},legacyimport.Report{DryRun, ProjectsImported, ProjectsSkipped, Notes}. Tasks 2 and 3 depend on thisReportshape. -
Step 1: Delete the orchestrator/transcript files.
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.Storeinterface: keep onlyGetProjectandUpsertProject(dropGetSession,ImportSession).Options: dropDataDirandClaudeProjectsDir; keepRoot,DryRun,Now,RepoOriginURL.Report: reduce to{DryRun bool, ProjectsImported int, ProjectsSkipped int, Notes []string}.- In
Run, delete the orchestrator block (thesessionsDir/readOrchestratorMapping/switch mapping.statusthat followsimportProject), so the loop body ends afterimportProject. - Delete the
importOrchestratorfunction. - Add the
quote()helper used by the project-id note:
// 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.goparsing-robustness fix (PR-bundled, improves project import).- Add
"errors"import. - Change the project config's
Repo string \yaml:"repo"`field toRepo *yaml.Node `yaml:"repo"`` with a comment that it is captured but never consumed (origin is re-resolved from the repo path). - In
loadLegacyConfig, whenyaml.Unmarshalerrors, keep the partial decode on a*yaml.TypeErrorinstead of failing the whole registry:
- Add
var typeErr *yaml.TypeError
if !errors.As(err, &typeErr) {
return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err)
}
- Step 4:
legacyimport/paths.go. DeletedefaultClaudeProjectsDirandprojectSessionsDir; 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(dropsessionsfromfakeStore, drop orchestrator/transcript assertions,writeLegacyRoot/runOptstake onlyroot),config_test.go, andproject_test.go(add thenonNilNode()helper returning a populated*yaml.Node) per the PR. - Step 6:
cli/import.go. Update the commandShort/Longto say "projects" only, the confirm prompt to "Import projects from %s?", and delete theOrchestrators:/Transcripts:lines fromwriteImportSummary. Remove theDataDirfield from thelegacyimport.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: noDataDir):
// 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.goPR-verbatim:fakeStoreimplements onlyGetProject/UpsertProject/ListProjects(the trimmedStoreneeds nothing else); testsTestStatus_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 thelegacyimportimport):
// 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.goPR-verbatim (ImportServiceinterface,ImportController,Register,status,run; nilSvcanswersapispec.NotImplemented). - Step 3: Create
imports_test.goPR-verbatim (fakeImportService,newImportTestServerusinghttpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Import: svc}, httpd.ControlDeps{}), testsTestImportAPI_Status/StatusError/Run/RunError). ThedoRequesthelper already exists in thecontrollers_testpackage. - Step 4:
go build ./...fails until Task 5 wiresAPIDeps.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.goadd, PR-verbatim: theimporttag (inTags), the schemaName mappings (ControllersImportStatusResponse->ImportStatusResponse,ControllersImportRunResponse->ImportRunResponse,LegacyimportReport->ImportReport), theimportOperations()func (2 ops), andops = append(ops, importOperations()...). - Step 2: Regenerate:
npm run api:specthennpm run api:ts. - Step 3: Inspect the diff. The generated
ImportReportschema must be the projects-only 4-field shape (dryRun,projectsImported,projectsSkipped,notes), matching the PR'sschema.tsverbatim. If orchestrator fields appear, Task 1'sReporttrim 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.goedits:APIDeps: addImport controllers.ImportService(afterNotificationStream).APIstruct: addimports *controllers.ImportController(afternotifications).NewAPI: addimports: &controllers.ImportController{Svc: deps.Import},(after thenotifications:line).Registertimeout group: adda.imports.Register(r)(aftera.notifications.Register(r)).
- Step 2:
daemon.goedits:- 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: noDataDir).
- Add import:
- 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
maybeFirstBootImportmethod and its call. Replace the call + the comment above it with:
// `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/legacyimportand…/internal/storage/sqlite. Keepconfig. Leavecli/import.go,confirm,stdinIsInteractive,writeImportSummaryuntouched. - 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
shouldAdoptDiscoveredPortdescribe block todaemon-discovery.test.ts(PR-verbatim). Runnpm --prefix frontend run test -- daemon-discovery→ expect FAIL (not exported). - Step 2: Add
shouldAdoptDiscoveredPort+ theDaemonStatustype import todaemon-discovery.ts(PR-verbatim). Re-run → expect PASS. - Step 3: In
main.ts: importshouldAdoptDiscoveredPort, addEXTERNAL_DISCOVERY_POLL_MS = 1_000,externalDiscoveryTimer,discoverExternalDaemonOnce(),startExternalDaemonDiscovery()(PR-verbatim); callstartExternalDaemonDiscovery()inapp.whenReady().then(...)and clear the timer inbefore-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 (
ImportReportTS type ={projectsImported, projectsSkipped, notes?}).useImportStatuspolls every 30s withthrowOnError: false;useRunImportinvalidatesimportStatusQueryKey+workspaceQueryKeyon 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.tsxPR-verbatim (mocks../lib/api-client; asserts offer-shown/hidden/accept/decline/error). Runnpm --prefix frontend run test -- ImportOffer→ expect FAIL (component missing). - Step 2: Create
ImportOffer.tsxPR-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 fromui/button(primary/ghost, sizesm). 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<DashboardSubhead .../>line, insert:
{
/* First-run legacy-AO import opt-in. Renders only when the daemon
reports an importable install, and only on the top-level board. */
}
{
!projectId && <ImportOffer />;
}
- 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 runon touched packages → clean.npm --prefix frontend run typecheck && npm --prefix frontend run test→ green.- Confirm
git statusshows no uncommitted drift inopenapi.yaml/schema.tsafter a freshnpm 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 previewthe dashboard against a daemon pointed at a seeded~/.agent-orchestratorlegacy 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.