diff --git a/backend/internal/cli/import.go b/backend/internal/cli/import.go index b809f8992..f25c9ec0d 100644 --- a/backend/internal/cli/import.go +++ b/backend/internal/cli/import.go @@ -27,12 +27,11 @@ func newImportCommand(ctx *commandContext) *cobra.Command { var opts importOptions cmd := &cobra.Command{ Use: "import", - Short: "Import projects and orchestrator sessions from a legacy AO install", + Short: "Import projects from a legacy AO install", Long: "Import reads the legacy Agent Orchestrator flat-file store " + - "(~/.agent-orchestrator) read-only and ports its projects, per-project " + - "settings, and each project's live orchestrator session into the rewrite " + - "database. Legacy files are never modified, and a re-run skips rows that " + - "already exist, so it is safe to run more than once.\n\n" + + "(~/.agent-orchestrator) read-only and ports its projects and per-project " + + "settings into the rewrite database. Legacy files are never modified, and " + + "a re-run skips rows that already exist, so it is safe to run more than once.\n\n" + "The daemon must be stopped: it is the sole writer of the database.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -71,7 +70,7 @@ func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error if !opts.dryRun && !opts.yes { ok, err := confirm(c.deps.In, cmd.OutOrStdout(), - fmt.Sprintf("Import projects and orchestrator sessions from %s?", root), true) + fmt.Sprintf("Import projects from %s?", root), true) if err != nil { return err } @@ -82,9 +81,8 @@ func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error } rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{ - Root: root, - DataDir: cfg.DataDir, - DryRun: opts.dryRun, + Root: root, + DryRun: opts.dryRun, }) if err != nil { return err @@ -112,11 +110,9 @@ func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, o func writeImportSummary(w io.Writer, rep legacyimport.Report) error { var b strings.Builder if rep.DryRun { - b.WriteString("Dry run — no changes written.\n") + b.WriteString("Dry run -- no changes written.\n") } - fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped) - fmt.Fprintf(&b, "Orchestrators: %d imported, %d skipped, %d absent\n", rep.OrchestratorsImported, rep.OrchestratorsSkipped, rep.OrchestratorsAbsent) - fmt.Fprintf(&b, "Transcripts: %d relocated\n", rep.TranscriptsRelocated) + fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped) if len(rep.Notes) > 0 { b.WriteString("\nNotes:\n") for _, n := range rep.Notes { diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index 185ea4b12..7d03e1260 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -75,8 +75,6 @@ func newStartCommand(ctx *commandContext) *cobra.Command { return cmd } -// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; standalone `ao import` still available. - // runStart implements the spec §6.1 algorithm: resolve the installed app, fetch // it if absent, open it, then print the deprecation notice. It never blocks or // supervises the launched app. diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index d565a16c5..891838815 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -21,6 +21,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/preview" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" @@ -127,6 +128,7 @@ func Run() error { Reviews: reviewSvc, Notifications: notifier, NotificationStream: notificationHub, + Import: importsvc.New(importsvc.Deps{Store: store}), CDC: store, Events: cdcPipe.Broadcaster, Activity: lcStack.LCM, diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 9026376d2..3ec736911 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -26,6 +26,7 @@ type APIDeps struct { Reviews reviewsvc.Manager Notifications controllers.NotificationService NotificationStream controllers.NotificationStream + Import controllers.ImportService CDC cdc.Source Events cdcSubscriber Telemetry ports.EventSink @@ -40,6 +41,7 @@ type API struct { prs *controllers.PRsController reviews *controllers.ReviewsController notifications *controllers.NotificationsController + imports *controllers.ImportController events *EventsController } @@ -59,6 +61,7 @@ func NewAPI(cfg config.Config, deps APIDeps) *API { prs: &controllers.PRsController{Svc: deps.PRs}, reviews: &controllers.ReviewsController{Svc: deps.Reviews}, notifications: &controllers.NotificationsController{Svc: deps.Notifications, Stream: deps.NotificationStream}, + imports: &controllers.ImportController{Svc: deps.Import}, events: &EventsController{Source: deps.CDC, Live: deps.Events}, } } @@ -82,6 +85,7 @@ func (a *API) Register(root chi.Router) { a.prs.Register(r) a.reviews.Register(r) a.notifications.Register(r) + a.imports.Register(r) // Sibling REST controllers plug in here. }) // Long-lived streams intentionally bypass the REST timeout middleware. diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a66191e55..2a3de1a9b 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -51,6 +51,55 @@ paths: summary: Stream CDC events with durable replay tags: - events + /api/v1/import: + get: + operationId: getImportStatus + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ImportStatusResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Check whether a legacy AO install is available to import + tags: + - import + post: + operationId: runImport + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ImportRunResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Run the legacy AO project import through the daemon store + tags: + - import /api/v1/notifications: get: operationId: listNotifications @@ -1600,6 +1649,40 @@ components: required: - harness type: object + ImportReport: + properties: + dryRun: + type: boolean + notes: + items: + type: string + type: array + projectsImported: + type: integer + projectsSkipped: + type: integer + required: + - dryRun + - projectsImported + - projectsSkipped + type: object + ImportRunResponse: + properties: + report: + $ref: '#/components/schemas/ImportReport' + required: + - report + type: object + ImportStatusResponse: + properties: + available: + type: boolean + legacyRoot: + type: string + required: + - available + - legacyRoot + type: object KillSessionResponse: properties: freed: @@ -2429,3 +2512,5 @@ tags: name: notifications - description: Server-sent CDC event stream with durable replay name: events +- description: Legacy AO project import (availability probe and run) + name: import diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index a2611d61f..713e1a156 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -67,6 +67,8 @@ func Build() ([]byte, error) { "Durable dashboard notifications"), *(&openapi31.Tag{Name: "events"}).WithDescription( "Server-sent CDC event stream with durable replay"), + *(&openapi31.Tag{Name: "import"}).WithDescription( + "Legacy AO project import (availability probe and run)"), } for _, op := range operations() { @@ -186,6 +188,11 @@ var schemaNames = map[string]string{ "ControllersSubmitReviewInput": "SubmitReviewInput", // domain review entities "DomainReviewRun": "ReviewRun", + // httpd/controllers: import wire envelopes + "ControllersImportStatusResponse": "ImportStatusResponse", + "ControllersImportRunResponse": "ImportRunResponse", + // legacyimport report + "LegacyimportReport": "ImportReport", // service/project entities + DTOs "ProjectProject": "Project", "ProjectSummary": "ProjectSummary", @@ -273,9 +280,35 @@ func operations() []operation { ops = append(ops, prOperations()...) ops = append(ops, reviewOperations()...) ops = append(ops, notificationOperations()...) + ops = append(ops, importOperations()...) return ops } +// importOperations declares the 2 /import operations. Must stay 1:1 with +// the routes ImportController.Register mounts (enforced by the parity test). +func importOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/import", id: "getImportStatus", tag: "import", + summary: "Check whether a legacy AO install is available to import", + resps: []respUnit{ + {http.StatusOK, controllers.ImportStatusResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/import", id: "runImport", tag: "import", + summary: "Run the legacy AO project import through the daemon store", + resps: []respUnit{ + {http.StatusOK, controllers.ImportRunResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} + func notificationOperations() []operation { return []operation{ { diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 16463bac1..f6d4f5e1b 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -6,6 +6,7 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -485,6 +486,19 @@ type MarkAllNotificationsReadResponse struct { Notifications []NotificationResponse `json:"notifications"` } +// 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"` +} + // PRIDParam is the {id} path parameter shared by the /prs/{id} routes. type PRIDParam struct { ID string `path:"id" description:"PR number."` diff --git a/backend/internal/httpd/controllers/imports.go b/backend/internal/httpd/controllers/imports.go new file mode 100644 index 000000000..20022184d --- /dev/null +++ b/backend/internal/httpd/controllers/imports.go @@ -0,0 +1,59 @@ +package controllers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" + importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" +) + +// ImportService is the controller-facing import service contract. +type ImportService interface { + Status(ctx context.Context) (importsvc.Status, error) + Run(ctx context.Context) (legacyimport.Report, error) +} + +// ImportController owns the /import routes. +type ImportController struct { + Svc ImportService +} + +// Register mounts import REST routes on the supplied router. +func (c *ImportController) Register(r chi.Router) { + r.Get("/import", c.status) + r.Post("/import", c.run) +} + +func (c *ImportController) status(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/import") + return + } + st, err := c.Svc.Status(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ImportStatusResponse{ + Available: st.Available, + LegacyRoot: st.LegacyRoot, + }) +} + +func (c *ImportController) run(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/import") + return + } + rep, err := c.Svc.Run(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ImportRunResponse{Report: rep}) +} diff --git a/backend/internal/httpd/controllers/imports_test.go b/backend/internal/httpd/controllers/imports_test.go new file mode 100644 index 000000000..87c4db3be --- /dev/null +++ b/backend/internal/httpd/controllers/imports_test.go @@ -0,0 +1,99 @@ +package controllers_test + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" + importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" +) + +// fakeImportService is a test double for controllers.ImportService. +type fakeImportService struct { + statusResult importsvc.Status + statusErr error + runResult legacyimport.Report + runErr error +} + +func (f *fakeImportService) Status(_ context.Context) (importsvc.Status, error) { + return f.statusResult, f.statusErr +} + +func (f *fakeImportService) Run(_ context.Context) (legacyimport.Report, error) { + return f.runResult, f.runErr +} + +func newImportTestServer(t *testing.T, svc controllers.ImportService) *httptest.Server { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Import: svc}, httpd.ControlDeps{})) + t.Cleanup(srv.Close) + return srv +} + +func TestImportAPI_Status(t *testing.T) { + svc := &fakeImportService{statusResult: importsvc.Status{Available: true, LegacyRoot: "/home/u/.agent-orchestrator"}} + srv := newImportTestServer(t, svc) + body, status, headers := doRequest(t, srv, "GET", "/api/v1/import", "") + if status != http.StatusOK { + t.Fatalf("GET /import = %d, want 200; body=%s", status, body) + } + assertJSON(t, headers) + var resp controllers.ImportStatusResponse + mustJSON(t, body, &resp) + if !resp.Available || resp.LegacyRoot != "/home/u/.agent-orchestrator" { + t.Fatalf("status = %+v", resp) + } +} + +func TestImportAPI_StatusError(t *testing.T) { + svc := &fakeImportService{statusErr: errors.New("store error")} + srv := newImportTestServer(t, svc) + _, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "") + if status != http.StatusInternalServerError { + t.Fatalf("GET /import with error = %d, want 500", status) + } +} + +func TestImportAPI_Run(t *testing.T) { + svc := &fakeImportService{runResult: legacyimport.Report{ProjectsImported: 3}} + srv := newImportTestServer(t, svc) + body, status, headers := doRequest(t, srv, "POST", "/api/v1/import", "") + if status != http.StatusOK { + t.Fatalf("POST /import = %d, want 200; body=%s", status, body) + } + assertJSON(t, headers) + var resp controllers.ImportRunResponse + mustJSON(t, body, &resp) + if resp.Report.ProjectsImported != 3 { + t.Fatalf("report = %+v", resp.Report) + } +} + +func TestImportAPI_RunError(t *testing.T) { + svc := &fakeImportService{runErr: errors.New("disk full")} + srv := newImportTestServer(t, svc) + _, status, _ := doRequest(t, srv, "POST", "/api/v1/import", "") + if status != http.StatusInternalServerError { + t.Fatalf("POST /import with error = %d, want 500", status) + } +} + +func TestImportAPI_NilSvcReturns501(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{})) + t.Cleanup(srv.Close) + body, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "") + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") + body, status, _ = doRequest(t, srv, "POST", "/api/v1/import", "") + assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") +} diff --git a/backend/internal/legacyimport/claude.go b/backend/internal/legacyimport/claude.go deleted file mode 100644 index 0c78a31c3..000000000 --- a/backend/internal/legacyimport/claude.go +++ /dev/null @@ -1,137 +0,0 @@ -package legacyimport - -import ( - "io" - "os" - "path/filepath" - "regexp" -) - -// claudeSlugRE matches every character Claude Code replaces with "-" when it -// buckets a cwd's transcripts under ~/.claude/projects//. The rule -// (empirically verified, issue #2129 §9) is: realpath(cwd) with every char -// outside [a-zA-Z0-9-] replaced by "-". A leading "/" therefore becomes a -// leading "-". -var claudeSlugRE = regexp.MustCompile(`[^a-zA-Z0-9-]`) - -func claudeSlug(path string) string { - return claudeSlugRE.ReplaceAllString(path, "-") -} - -// transcriptCopyPlan is the resolved source + destination of a transcript copy. -type transcriptCopyPlan struct { - uuid string - sourcePath string // ~/.claude/projects//.jsonl - destPath string // ~/.claude/projects//.jsonl -} - -// planTranscriptCopy computes the source + destination transcript paths. -// -// Claude Code buckets a transcript under ~/.claude/projects// where the -// slug is derived from the REALPATH of the session's cwd. Both slugs are -// therefore computed from symlink-resolved paths: -// -// - source: the legacy worktree the orchestrator last ran in (exists on disk). -// - destination: the orchestrator worktree the rewrite materialises on first -// resume — {dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator. -// The daemon resolves that path through physicalAbs before cd-ing into it -// (gitworktree New + validateManagedPath), so we resolve it the same way; a -// literal-path slug would miss the resume bucket whenever any component of -// dataDir (e.g. a custom AO_DATA_DIR, or macOS /tmp → /private/tmp) is a -// symlink. The leaf does not exist yet, so resolvePhysical resolves the -// longest existing ancestor and appends the literal tail — exactly what the -// daemon's physicalAbs does. -func planTranscriptCopy(dataDir, projectID, prefix, worktree, uuid, claudeProjectsDir string) transcriptCopyPlan { - if claudeProjectsDir == "" { - claudeProjectsDir = defaultClaudeProjectsDir() - } - sourceSlug := claudeSlug(resolvePhysical(worktree)) - - destTemplate := filepath.Join(dataDir, "worktrees", projectID, "orchestrator", prefix+"-orchestrator") - destSlug := claudeSlug(resolvePhysical(destTemplate)) - - return transcriptCopyPlan{ - uuid: uuid, - sourcePath: filepath.Join(claudeProjectsDir, sourceSlug, uuid+".jsonl"), - destPath: filepath.Join(claudeProjectsDir, destSlug, uuid+".jsonl"), - } -} - -// resolvePhysical resolves path to an absolute, symlink-free path, mirroring the -// daemon's gitworktree.physicalAbs so the transcript destination slug matches the -// cwd the resumed orchestrator actually runs in. When the leaf does not exist -// yet it resolves the longest existing ancestor and appends the literal tail. -func resolvePhysical(path string) string { - abs, err := filepath.Abs(path) - if err != nil { - return filepath.Clean(path) - } - abs = filepath.Clean(abs) - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - return filepath.Clean(resolved) - } - parent := filepath.Dir(abs) - base := filepath.Base(abs) - for parent != "." && parent != string(os.PathSeparator) { - if resolved, err := filepath.EvalSymlinks(parent); err == nil { - return filepath.Join(resolved, base) - } - base = filepath.Join(filepath.Base(parent), base) - parent = filepath.Dir(parent) - } - if resolved, err := filepath.EvalSymlinks(parent); err == nil { - return filepath.Join(resolved, base) - } - return abs -} - -// transcriptOutcome reports what relocateTranscript did. -type transcriptOutcome string - -const ( - transcriptCopied transcriptOutcome = "copied" - transcriptAlreadyPresent transcriptOutcome = "already-present" - transcriptSourceMissing transcriptOutcome = "source-missing" -) - -// relocateTranscript executes a transcript copy. Idempotent: an existing -// destination is left as-is (already-present); a missing source is skipped -// (source-missing). Only "copied" counts as a relocation. The legacy source is -// never modified. -func relocateTranscript(plan transcriptCopyPlan) (transcriptOutcome, error) { - if pathExists(plan.destPath) { - return transcriptAlreadyPresent, nil - } - if !pathExists(plan.sourcePath) { - return transcriptSourceMissing, nil - } - if err := os.MkdirAll(filepath.Dir(plan.destPath), 0o750); err != nil { - return "", err - } - if err := copyFile(plan.sourcePath, plan.destPath); err != nil { - return "", err - } - return transcriptCopied, nil -} - -func pathExists(p string) bool { - _, err := os.Stat(p) - return err == nil -} - -func copyFile(src, dst string) error { - in, err := os.Open(src) //nolint:gosec // src is a resolved transcript path under ~/.claude - if err != nil { - return err - } - defer func() { _ = in.Close() }() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - return out.Close() -} diff --git a/backend/internal/legacyimport/claude_test.go b/backend/internal/legacyimport/claude_test.go deleted file mode 100644 index 49ff80443..000000000 --- a/backend/internal/legacyimport/claude_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package legacyimport - -import ( - "os" - "path/filepath" - "testing" - - yaml "gopkg.in/yaml.v3" -) - -func nonNilNode() *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Value: "x"} } - -func TestClaudeSlug(t *testing.T) { - if got := claudeSlug("/Users/me/Code/proj.x"); got != "-Users-me-Code-proj-x" { - t.Fatalf("slug = %q", got) - } -} - -func TestPlanTranscriptCopy_DestUsesOrchestratorTemplate(t *testing.T) { - plan := planTranscriptCopy("/data", "proj", "pre", "/legacy/wt", "uuid-1", "/claude") - // Destination slug = slug({dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator). - wantDest := filepath.Join("/claude", claudeSlug("/data/worktrees/proj/orchestrator/pre-orchestrator"), "uuid-1.jsonl") - if plan.destPath != wantDest { - t.Fatalf("destPath = %q, want %q", plan.destPath, wantDest) - } -} - -func TestRelocateTranscript_CopiesAndIsIdempotent(t *testing.T) { - dir := t.TempDir() - claudeDir := filepath.Join(dir, "claude") - worktree := filepath.Join(dir, "wt") - if err := os.MkdirAll(worktree, 0o750); err != nil { - t.Fatal(err) - } - // Seed the legacy transcript at the source slug. planTranscriptCopy - // realpath-resolves the worktree, so seed under the resolved slug (matters on - // macOS where /var/folders is a symlink to /private/var/folders). - resolvedWt, err := filepath.EvalSymlinks(worktree) - if err != nil { - t.Fatal(err) - } - srcSlug := claudeSlug(resolvedWt) - srcDir := filepath.Join(claudeDir, srcSlug) - if err := os.MkdirAll(srcDir, 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(srcDir, "uuid-1.jsonl"), []byte("hello"), 0o600); err != nil { - t.Fatal(err) - } - - plan := planTranscriptCopy(filepath.Join(dir, "data"), "proj", "pre", worktree, "uuid-1", claudeDir) - out, err := relocateTranscript(plan) - if err != nil || out != transcriptCopied { - t.Fatalf("relocate = (%s,%v), want copied", out, err) - } - if b, err := os.ReadFile(plan.destPath); err != nil || string(b) != "hello" { - t.Fatalf("dest content = %q err=%v", b, err) - } - // Re-run: destination already present. - if out, _ := relocateTranscript(plan); out != transcriptAlreadyPresent { - t.Fatalf("second relocate = %s, want already-present", out) - } -} - -func TestPlanTranscriptCopy_DestResolvesSymlinkedDataDir(t *testing.T) { - // The daemon resolves the orchestrator worktree through physicalAbs before - // cd-ing into it, so the dest slug must use the symlink-resolved data dir — - // not the literal one — or `claude --resume` misses the bucket. - realData := t.TempDir() - linkDir := filepath.Join(t.TempDir(), "data-link") - if err := os.Symlink(realData, linkDir); err != nil { - t.Skipf("symlink unsupported: %v", err) - } - plan := planTranscriptCopy(linkDir, "proj", "pre", "/legacy/wt", "uuid-1", "/claude") - - resolvedReal, err := filepath.EvalSymlinks(realData) - if err != nil { - t.Fatal(err) - } - wantSlug := claudeSlug(filepath.Join(resolvedReal, "worktrees", "proj", "orchestrator", "pre-orchestrator")) - wantDest := filepath.Join("/claude", wantSlug, "uuid-1.jsonl") - if plan.destPath != wantDest { - t.Fatalf("destPath = %q,\n want %q (resolved, not the symlinked %q)", plan.destPath, wantDest, linkDir) - } -} - -func TestRelocateTranscript_SourceMissing(t *testing.T) { - plan := planTranscriptCopy(t.TempDir(), "proj", "pre", "/nope/wt", "uuid-x", filepath.Join(t.TempDir(), "claude")) - if out, err := relocateTranscript(plan); err != nil || out != transcriptSourceMissing { - t.Fatalf("relocate = (%s,%v), want source-missing", out, err) - } -} diff --git a/backend/internal/legacyimport/config.go b/backend/internal/legacyimport/config.go index 9633adcd9..bed1dabd6 100644 --- a/backend/internal/legacyimport/config.go +++ b/backend/internal/legacyimport/config.go @@ -2,6 +2,7 @@ package legacyimport import ( "encoding/json" + "errors" "fmt" "os" @@ -22,7 +23,9 @@ type legacyConfig struct { type legacyProjectConfig struct { Path string `yaml:"path"` Name string `yaml:"name"` - Repo string `yaml:"repo"` + // Repo is captured as a raw YAML node but never consumed; the origin URL is + // re-resolved from the repo path at import time. + Repo *yaml.Node `yaml:"repo"` DefaultBranch string `yaml:"defaultBranch"` SessionPrefix string `yaml:"sessionPrefix"` Env map[string]string `yaml:"env"` @@ -65,7 +68,12 @@ func loadLegacyConfig(root string) (legacyConfig, error) { } var cfg legacyConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + } + // A type mismatch (e.g. a scalar where a mapping is expected) is a + // partial decode: keep the decoded fields and continue. } return cfg, nil } diff --git a/backend/internal/legacyimport/importer.go b/backend/internal/legacyimport/importer.go index f10841259..ec637daf7 100644 --- a/backend/internal/legacyimport/importer.go +++ b/backend/internal/legacyimport/importer.go @@ -15,42 +15,31 @@ import ( // Store is the narrow slice of the rewrite's native storage layer the importer // writes through. *sqlite.Store satisfies it. Idempotency lives here: a project -// or orchestrator whose id already exists is skipped, never overwritten, so a -// re-run is safe and legacy files stay the sole source of truth. +// whose id already exists is skipped, never overwritten, so a re-run is safe +// and legacy files stay the sole source of truth. type Store interface { GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) UpsertProject(ctx context.Context, r domain.ProjectRecord) error - GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) - ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error) } // Options configure one import run. type Options struct { // Root is the legacy state root to read (default ~/.agent-orchestrator). Root string - // DataDir is the rewrite data dir, used only to compute the destination - // transcript slug. It must match the daemon's AO_DATA_DIR. - DataDir string - // DryRun parses + plans every row and relocation but writes nothing. + // DryRun parses + plans every row but writes nothing. DryRun bool - // ClaudeProjectsDir overrides ~/.claude/projects (tests). - ClaudeProjectsDir string - // Now is the fallback registered_at timestamp. Zero → time.Now().UTC(). + // Now is the fallback registered_at timestamp. Zero -> time.Now().UTC(). Now time.Time - // RepoOriginURL resolves a repo's git origin. Nil → the real git resolver. + // RepoOriginURL resolves a repo's git origin. Nil -> the real git resolver. RepoOriginURL func(path string) string } // Report is the structured outcome of an import run. type Report struct { - DryRun bool `json:"dryRun"` - ProjectsImported int `json:"projectsImported"` - ProjectsSkipped int `json:"projectsSkipped"` // already present - OrchestratorsImported int `json:"orchestratorsImported"` - OrchestratorsSkipped int `json:"orchestratorsSkipped"` // terminal / non-importable / already present - OrchestratorsAbsent int `json:"orchestratorsAbsent"` - TranscriptsRelocated int `json:"transcriptsRelocated"` - Notes []string `json:"notes,omitempty"` + DryRun bool `json:"dryRun"` + ProjectsImported int `json:"projectsImported"` + ProjectsSkipped int `json:"projectsSkipped"` // already present + Notes []string `json:"notes,omitempty"` } // HasLegacyData reports whether root holds an importable legacy store: a @@ -76,11 +65,10 @@ func isValidRewriteProjectID(id string) bool { !strings.ContainsAny(id, `/\`) && rewriteProjectID.MatchString(id) } -// Run reads the legacy store and writes projects (then orchestrator sessions) -// into store, relocating claude-code transcripts. It never modifies legacy -// files. It is idempotent: existing rows are skipped. A per-project parse or -// write failure is recorded as a note and does not abort the whole run, except a -// store write error, which is returned. +// Run reads the legacy store and writes projects into store. It never modifies +// legacy files. It is idempotent: existing rows are skipped. A per-project +// parse or write failure is recorded as a note and does not abort the whole +// run, except a store write error, which is returned. func Run(ctx context.Context, store Store, opts Options) (Report, error) { root := opts.Root if root == "" { @@ -113,7 +101,7 @@ func Run(ctx context.Context, store Store, opts Options) (Report, error) { prefs := loadPreferences(root) reg := loadRegistered(root) - // Deterministic order: projects before sessions, ids sorted. + // Deterministic order: ids sorted. ids := make([]string, 0, len(cfg.Projects)) for id := range cfg.Projects { ids = append(ids, id) @@ -135,23 +123,6 @@ func Run(ctx context.Context, store Store, opts Options) (Report, error) { if err := importProject(ctx, store, record, opts.DryRun, &rep); err != nil { return rep, err } - - // Orchestrator session for this project. - sessionsDir := projectSessionsDir(root, id) - mapping := readOrchestratorMapping(sessionsDir, id, pc) - if mapping.note != "" { - rep.Notes = append(rep.Notes, id+": "+mapping.note) - } - switch mapping.status { - case orchAbsent: - rep.OrchestratorsAbsent++ - case orchSkipped: - rep.OrchestratorsSkipped++ - case orchMapped: - if err := importOrchestrator(ctx, store, mapping, opts, &rep); err != nil { - return rep, err - } - } } return rep, nil } @@ -176,52 +147,6 @@ func importProject(ctx context.Context, store Store, record domain.ProjectRecord return nil } -func importOrchestrator(ctx context.Context, store Store, mapping orchestratorMapping, opts Options, rep *Report) error { - rec := mapping.record - _, exists, err := store.GetSession(ctx, rec.ID) - if err != nil { - return fmt.Errorf("lookup orchestrator %s: %w", rec.ID, err) - } - if exists { - rep.OrchestratorsSkipped++ - } else if opts.DryRun { - rep.OrchestratorsImported++ - } else { - inserted, err := store.ImportSession(ctx, rec, 0) - if err != nil { - return fmt.Errorf("write orchestrator %s: %w", rec.ID, err) - } - if inserted { - rep.OrchestratorsImported++ - } else { - rep.OrchestratorsSkipped++ - } - } - - // Relocate the claude-code transcript (codex/opencode resume by global id). - if mapping.transcript == nil { - return nil - } - plan := planTranscriptCopy(opts.DataDir, mapping.projectID, mapping.prefix, - mapping.transcript.worktree, mapping.transcript.uuid, opts.ClaudeProjectsDir) - if opts.DryRun { - if _, err := os.Stat(plan.sourcePath); err == nil { - rep.TranscriptsRelocated++ - } - return nil - } - // Relocation is best-effort: a failure is noted, not fatal — the orchestrator - // still resumes, just without prior context. - outcome, relocErr := relocateTranscript(plan) - switch { - case relocErr != nil: - rep.Notes = append(rep.Notes, mapping.projectID+": transcript relocation failed: "+relocErr.Error()) - case outcome == transcriptCopied: - rep.TranscriptsRelocated++ - } - return nil -} - func appendPrefixed(dst []string, id string, notes []string) []string { for _, n := range notes { dst = append(dst, id+": "+n) @@ -229,6 +154,15 @@ func appendPrefixed(dst []string, id string, notes []string) []string { return dst } +// 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 + `"` +} + // defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is // absent or has no origin. Matches the rewrite's resolveGitOriginURL. func defaultRepoOriginURL(path string) string { diff --git a/backend/internal/legacyimport/importer_test.go b/backend/internal/legacyimport/importer_test.go index f0ce33af0..44f76c31b 100644 --- a/backend/internal/legacyimport/importer_test.go +++ b/backend/internal/legacyimport/importer_test.go @@ -13,11 +13,10 @@ import ( // fakeStore is an in-memory Store with the importer's idempotency semantics. type fakeStore struct { projects map[string]domain.ProjectRecord - sessions map[domain.SessionID]domain.SessionRecord } func newFakeStore() *fakeStore { - return &fakeStore{projects: map[string]domain.ProjectRecord{}, sessions: map[domain.SessionID]domain.SessionRecord{}} + return &fakeStore{projects: map[string]domain.ProjectRecord{}} } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { @@ -28,25 +27,12 @@ func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) err f.projects[r.ID] = r return nil } -func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - r, ok := f.sessions[id] - return r, ok, nil -} -func (f *fakeStore) ImportSession(_ context.Context, rec domain.SessionRecord, _ int64) (bool, error) { - if _, ok := f.sessions[rec.ID]; ok { - return false, nil - } - f.sessions[rec.ID] = rec - return true, nil -} -// writeLegacyRoot builds a minimal legacy store: two projects, an importable -// claude-code orchestrator for alpha (with a seeded transcript), an aider -// orchestrator for beta (skipped). Returns the legacy root and the claude dir. -func writeLegacyRoot(t *testing.T) (root, claudeDir string) { +// writeLegacyRoot builds a minimal legacy store: two projects. Returns the +// legacy root. +func writeLegacyRoot(t *testing.T) string { t.Helper() - root = filepath.Join(t.TempDir(), ".agent-orchestrator") - claudeDir = filepath.Join(t.TempDir(), "claude") + root := filepath.Join(t.TempDir(), ".agent-orchestrator") mustMkdir(t, filepath.Join(root, "projects", "alpha", "sessions")) mustMkdir(t, filepath.Join(root, "projects", "beta", "sessions")) @@ -58,72 +44,29 @@ func writeLegacyRoot(t *testing.T) (root, claudeDir string) { beta: path: /repos/beta `) - - worktree := filepath.Join(t.TempDir(), "alpha-wt") - mustMkdir(t, worktree) - mustWrite(t, filepath.Join(root, "projects", "alpha", "sessions", "orchestrator.json"), `{ - "role": "orchestrator", - "agent": "claude-code", - "worktree": "`+worktree+`", - "claudeSessionUuid": "uuid-alpha", - "userPrompt": "go", - "createdAt": "2026-01-01T00:00:00Z", - "lifecycle": {"session": {"state": "working", "lastTransitionAt": "2026-01-02T00:00:00Z"}} - }`) - // Seed the transcript at the legacy source slug so relocation copies it - // (resolve symlinks to match planTranscriptCopy's realpath of the worktree). - resolvedWt, err := filepath.EvalSymlinks(worktree) - if err != nil { - t.Fatal(err) - } - srcDir := filepath.Join(claudeDir, claudeSlug(resolvedWt)) - mustMkdir(t, srcDir) - mustWrite(t, filepath.Join(srcDir, "uuid-alpha.jsonl"), "transcript") - - mustWrite(t, filepath.Join(root, "projects", "beta", "sessions", "orchestrator.json"), `{ - "role": "orchestrator", - "agent": "aider", - "lifecycle": {"session": {"state": "working"}} - }`) - return root, claudeDir + return root } -func runOpts(root, claudeDir string) Options { +func runOpts(root string) Options { return Options{ - Root: root, - DataDir: filepath.Join(filepath.Dir(root), "data"), - ClaudeProjectsDir: claudeDir, - Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), - RepoOriginURL: func(string) string { return "" }, + Root: root, + Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + RepoOriginURL: func(string) string { return "" }, } } func TestRun_EndToEnd(t *testing.T) { - root, claudeDir := writeLegacyRoot(t) + root := writeLegacyRoot(t) store := newFakeStore() ctx := context.Background() - rep, err := Run(ctx, store, runOpts(root, claudeDir)) + rep, err := Run(ctx, store, runOpts(root)) if err != nil { t.Fatalf("run: %v", err) } if rep.ProjectsImported != 2 { t.Fatalf("projectsImported = %d, want 2", rep.ProjectsImported) } - if rep.OrchestratorsImported != 1 { - t.Fatalf("orchestratorsImported = %d, want 1 (alpha)", rep.OrchestratorsImported) - } - if rep.OrchestratorsSkipped != 1 { - t.Fatalf("orchestratorsSkipped = %d, want 1 (beta/aider)", rep.OrchestratorsSkipped) - } - if rep.TranscriptsRelocated != 1 { - t.Fatalf("transcriptsRelocated = %d, want 1", rep.TranscriptsRelocated) - } - // The alpha orchestrator row landed verbatim. - o, ok := store.sessions["alpha-orchestrator"] - if !ok || o.Kind != domain.KindOrchestrator || o.Metadata.AgentSessionID != "uuid-alpha" { - t.Fatalf("alpha orchestrator = %+v ok=%v", o, ok) - } // develop branch survives into the config blob. if store.projects["alpha"].Config.DefaultBranch != "develop" { t.Fatalf("alpha config = %+v", store.projects["alpha"].Config) @@ -131,37 +74,34 @@ func TestRun_EndToEnd(t *testing.T) { } func TestRun_Idempotent(t *testing.T) { - root, claudeDir := writeLegacyRoot(t) + root := writeLegacyRoot(t) store := newFakeStore() ctx := context.Background() - if _, err := Run(ctx, store, runOpts(root, claudeDir)); err != nil { + if _, err := Run(ctx, store, runOpts(root)); err != nil { t.Fatalf("first run: %v", err) } - rep, err := Run(ctx, store, runOpts(root, claudeDir)) + rep, err := Run(ctx, store, runOpts(root)) if err != nil { t.Fatalf("second run: %v", err) } if rep.ProjectsImported != 0 || rep.ProjectsSkipped != 2 { t.Fatalf("re-run projects: imported=%d skipped=%d, want 0/2", rep.ProjectsImported, rep.ProjectsSkipped) } - if rep.OrchestratorsImported != 0 { - t.Fatalf("re-run orchestratorsImported = %d, want 0", rep.OrchestratorsImported) - } } func TestRun_DryRunWritesNothing(t *testing.T) { - root, claudeDir := writeLegacyRoot(t) + root := writeLegacyRoot(t) store := newFakeStore() - opts := runOpts(root, claudeDir) + opts := runOpts(root) opts.DryRun = true rep, err := Run(context.Background(), store, opts) if err != nil { t.Fatalf("dry run: %v", err) } - if rep.ProjectsImported != 2 || rep.OrchestratorsImported != 1 { + if rep.ProjectsImported != 2 { t.Fatalf("dry-run plan = %+v", rep) } - if len(store.projects) != 0 || len(store.sessions) != 0 { + if len(store.projects) != 0 { t.Fatal("dry run must not write to the store") } } @@ -178,7 +118,7 @@ func TestRun_NoLegacyData(t *testing.T) { } func TestHasLegacyData(t *testing.T) { - root, _ := writeLegacyRoot(t) + root := writeLegacyRoot(t) if !HasLegacyData(root) { t.Fatal("HasLegacyData = false, want true") } diff --git a/backend/internal/legacyimport/orchestrator.go b/backend/internal/legacyimport/orchestrator.go deleted file mode 100644 index ec55d5c0e..000000000 --- a/backend/internal/legacyimport/orchestrator.go +++ /dev/null @@ -1,355 +0,0 @@ -package legacyimport - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// migratableHarnesses are the orchestrator harnesses the importer ports. aider -// (and anything else) is skipped with a note (gist §6). -var migratableHarnesses = map[string]bool{ - "claude-code": true, - "codex": true, - "opencode": true, -} - -// terminalStates are the legacy canonical states that mean "do not import". -var terminalStates = map[string]bool{"done": true, "terminated": true} - -// orchestratorStatus is the outcome of mapping one project's orchestrator. -type orchestratorStatus string - -const ( - orchMapped orchestratorStatus = "mapped" - orchSkipped orchestratorStatus = "skipped" - orchAbsent orchestratorStatus = "absent" -) - -// transcriptRelocation carries the inputs to relocate a claude-code transcript. -type transcriptRelocation struct { - worktree string // legacy worktree path on disk (realpath-resolved by the relocator) - uuid string // claudeSessionUuid = the transcript filename stem -} - -// orchestratorMapping is the mapped orchestrator session plus its transcript -// relocation (claude-code only) and any skip/lossy note. -type orchestratorMapping struct { - projectID string - prefix string - status orchestratorStatus - record domain.SessionRecord // valid when status == orchMapped - transcript *transcriptRelocation - note string -} - -// asObject coerces a JSON value that may be an object OR a JSON-encoded string -// into a decoded map, mirroring the legacy reader's double-decode. -func asObject(v any) map[string]any { - switch t := v.(type) { - case map[string]any: - return t - case string: - s := strings.TrimSpace(t) - if s == "" { - return nil - } - var parsed any - if err := json.Unmarshal([]byte(s), &parsed); err == nil { - if m, ok := parsed.(map[string]any); ok { - return m - } - } - } - return nil -} - -func asString(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" -} - -// isStateVersion2 reports whether a legacy stateVersion marks a V2 record. It -// accepts both the string "2" the legacy writer emits and a numeric 2, since -// JSON numbers decode to float64 through the untyped map. -func isStateVersion2(v any) bool { - switch t := v.(type) { - case string: - return t == "2" - case float64: - return t == 2 - } - return false -} - -// legacyLifecycle is the decoded session/runtime halves of the V2 lifecycle. -type legacyLifecycle struct { - session map[string]any - runtime map[string]any -} - -// extractLifecycle pulls the lifecycle, double-decoding stringified nested -// fields. It prefers the V2 "lifecycle" key, falling back to "statePayload" -// when stateVersion == "2" (mirrors parseLifecycleField). -func extractLifecycle(raw map[string]any) (legacyLifecycle, bool) { - lc := asObject(raw["lifecycle"]) - if lc == nil && isStateVersion2(raw["stateVersion"]) { - lc = asObject(raw["statePayload"]) - } - if lc == nil { - return legacyLifecycle{}, false - } - return legacyLifecycle{ - session: asObject(lc["session"]), - runtime: asObject(lc["runtime"]), - }, true -} - -// mapActivityState maps the legacy 8-state enum to a rewrite activity_state -// (issue #247 §2.1). Only non-terminal states reach here (terminal orchestrators -// are skipped upstream), so done/terminated need no mapping. -func mapActivityState(state string) domain.ActivityState { - switch state { - case "working": - return domain.ActivityActive - case "needs_input": - return domain.ActivityWaitingInput - default: - // not_started / idle / detecting / stuck / unknown → idle. - return domain.ActivityIdle - } -} - -// resumeID picks the rewrite agent_session_id by harness (issue #247 §2.2). -// codex carries codexModel and any harness may carry restoreFallbackReason in -// the legacy record; neither has a rewrite column (the single agent_session_id -// holds only the resume id), so both are dropped — the importer notes them. -func resumeID(harness string, raw map[string]any) string { - switch harness { - case "claude-code": - return asString(raw["claudeSessionUuid"]) - case "codex": - return asString(raw["codexThreadId"]) - case "opencode": - return asString(raw["opencodeSessionId"]) - default: - return "" - } -} - -// mapOrchestratorRecord maps a parsed legacy orchestrator record to a rewrite -// session record. Pure. fileMtime is the last-resort created_at when the record -// carries neither createdAt nor lifecycle.session.startedAt. -func mapOrchestratorRecord(raw map[string]any, projectID, prefix string, fileMtime time.Time) orchestratorMapping { - base := orchestratorMapping{projectID: projectID, prefix: prefix} - - lc, _ := extractLifecycle(raw) - state := asString(lc.session["state"]) - _, hasTerminatedAt := lc.session["terminatedAt"] - terminatedAtNonNull := hasTerminatedAt && lc.session["terminatedAt"] != nil - - // Import ONLY non-terminal, non-terminated orchestrators (gist §6). - if (state != "" && terminalStates[state]) || terminatedAtNonNull { - base.status = orchSkipped - base.note = "orchestrator is terminal (state=" + emptyDash(state) + ")" - return base - } - - agent := asString(raw["agent"]) - if !migratableHarnesses[agent] { - base.status = orchSkipped - base.note = "harness " + quote(agent) + " is not importable (only claude-code, codex, opencode)" - return base - } - - createdAt := firstTime(asString(raw["createdAt"]), asString(lc.session["startedAt"])) - if createdAt.IsZero() { - createdAt = fileMtime - } - activityLastAt := firstTime(asString(lc.session["lastTransitionAt"]), asString(lc.runtime["lastObservedAt"])) - if activityLastAt.IsZero() { - activityLastAt = createdAt - } - updatedAt := firstTime(asString(lc.session["lastTransitionAt"])) - if updatedAt.IsZero() { - updatedAt = createdAt - } - - worktree := asString(raw["worktree"]) - rec := domain.SessionRecord{ - ID: domain.SessionID(prefix + "-orchestrator"), - ProjectID: domain.ProjectID(projectID), - Kind: domain.KindOrchestrator, - Harness: domain.AgentHarness(agent), - DisplayName: asString(raw["displayName"]), - Activity: domain.Activity{ - State: mapActivityState(state), - LastActivityAt: activityLastAt, - }, - FirstSignalAt: activityLastAt, // backfill mirrors migration 0010 (#247 §2.1) - IsTerminated: false, - Metadata: domain.SessionMetadata{ - Branch: asString(raw["branch"]), - WorkspacePath: worktree, - AgentSessionID: resumeID(agent, raw), - Prompt: asString(raw["userPrompt"]), - }, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - base.status = orchMapped - base.record = rec - - // Note resume metadata the single agent_session_id column cannot hold. - var dropped []string - if agent == "codex" { - if m := asString(raw["codexModel"]); m != "" { - dropped = append(dropped, "codexModel "+quote(m)+" dropped (no rewrite column; codex resumes by thread id)") - } - } - if r := asString(raw["restoreFallbackReason"]); r != "" { - dropped = append(dropped, "restoreFallbackReason dropped (forensic only)") - } - base.note = strings.Join(dropped, "; ") - - // claude-code orchestrators carry a transcript to relocate (needs both a - // uuid and a worktree to compute source + destination slugs). - if agent == "claude-code" { - if uuid := asString(raw["claudeSessionUuid"]); uuid != "" && worktree != "" { - base.transcript = &transcriptRelocation{worktree: worktree, uuid: uuid} - } - } - return base -} - -// resolveOrchestratorPrefix resolves the import prefix: configured sessionPrefix, -// else the first 12 chars of the project id (matching the rewrite's -// resolvedSessionPrefix and the display-prefix convention). -func resolveOrchestratorPrefix(projectID string, pc legacyProjectConfig) string { - if p := strings.TrimSpace(pc.SessionPrefix); p != "" { - return p - } - if len(projectID) <= 12 { - return projectID - } - return projectID[:12] -} - -// parseJSONRecord parses JSON; nil on invalid/non-object content. -func parseJSONRecord(content string) map[string]any { - var parsed any - if err := json.Unmarshal([]byte(content), &parsed); err != nil { - return nil - } - if m, ok := parsed.(map[string]any); ok { - return m - } - return nil -} - -// findOrchestratorFile locates a project's orchestrator metadata file: the -// sessions-dir record whose raw role == "orchestrator", else the one named -// "{prefix}-orchestrator.json", else the legacy "orchestrator.json". Skips -// 0-byte and "*.corrupt-*" files (issue #2129 §8.1). -func findOrchestratorFile(sessionsDir, prefix string) string { - if sessionsDir == "" { - return "" - } - entries, err := os.ReadDir(sessionsDir) - if err != nil { - return "" - } - var byName string - for _, e := range entries { - name := e.Name() - if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.Contains(name, ".corrupt-") { - continue - } - file := filepath.Join(sessionsDir, name) - content, err := os.ReadFile(file) - if err != nil { - continue - } - trimmed := strings.TrimSpace(string(content)) - if trimmed == "" { - continue // 0-byte / reserved id - } - raw := parseJSONRecord(trimmed) - if raw == nil { - continue - } - if asString(raw["role"]) == "orchestrator" { - return file - } - if strings.TrimSuffix(name, ".json") == prefix+"-orchestrator" { - byName = file - } - } - if byName != "" { - return byName - } - // Defensive: the pre-V2 standalone orchestrator file. - legacy := filepath.Join(filepath.Dir(sessionsDir), "orchestrator.json") - if content, err := os.ReadFile(legacy); err == nil && strings.TrimSpace(string(content)) != "" { - return legacy - } - return "" -} - -// readOrchestratorMapping reads + maps a project's orchestrator. It returns -// absent when there is no orchestrator file, skipped for terminal/non-importable -// ones, and mapped (with the record and any transcript) otherwise. -func readOrchestratorMapping(sessionsDir, projectID string, pc legacyProjectConfig) orchestratorMapping { - prefix := resolveOrchestratorPrefix(projectID, pc) - file := findOrchestratorFile(sessionsDir, prefix) - if file == "" { - return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent} - } - content, err := os.ReadFile(file) - if err != nil { - return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent} - } - raw := parseJSONRecord(strings.TrimSpace(string(content))) - if raw == nil { - return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent} - } - mtime := time.Unix(0, 0).UTC() - if info, err := os.Stat(file); err == nil { - mtime = info.ModTime().UTC() - } - return mapOrchestratorRecord(raw, projectID, prefix, mtime) -} - -// firstTime returns the first RFC3339-parseable timestamp, or zero time. -func firstTime(candidates ...string) time.Time { - for _, c := range candidates { - if c == "" { - continue - } - if t, err := time.Parse(time.RFC3339, c); err == nil { - return t.UTC() - } - } - return time.Time{} -} - -func emptyDash(s string) string { - if s == "" { - return "?" - } - return s -} - -func quote(s string) string { - if s == "" { - return `"?"` - } - return `"` + s + `"` -} diff --git a/backend/internal/legacyimport/orchestrator_test.go b/backend/internal/legacyimport/orchestrator_test.go deleted file mode 100644 index 8e5b6305d..000000000 --- a/backend/internal/legacyimport/orchestrator_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package legacyimport - -import ( - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func mtime() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) } - -func TestMapOrchestrator_ClaudeMapped(t *testing.T) { - raw := map[string]any{ - "agent": "claude-code", - "role": "orchestrator", - "branch": "main", - "worktree": "/legacy/wt", - "userPrompt": "orchestrate", - "displayName": "Orch", - "claudeSessionUuid": "uuid-123", - "createdAt": "2026-01-01T00:00:00Z", - "lifecycle": map[string]any{ - "session": map[string]any{ - "state": "working", - "lastTransitionAt": "2026-01-01T01:00:00Z", - }, - }, - } - m := mapOrchestratorRecord(raw, "proj", "proj", mtime()) - if m.status != orchMapped { - t.Fatalf("status = %s, want mapped (note=%q)", m.status, m.note) - } - r := m.record - if r.ID != "proj-orchestrator" || r.Kind != domain.KindOrchestrator { - t.Fatalf("id/kind = %s/%s", r.ID, r.Kind) - } - if r.Activity.State != domain.ActivityActive { - t.Fatalf("activity = %s, want active", r.Activity.State) - } - if r.Metadata.AgentSessionID != "uuid-123" { - t.Fatalf("agentSessionID = %q, want uuid-123", r.Metadata.AgentSessionID) - } - if r.CreatedAt.Format(time.RFC3339) != "2026-01-01T00:00:00Z" { - t.Fatalf("createdAt = %s", r.CreatedAt) - } - if m.transcript == nil || m.transcript.uuid != "uuid-123" || m.transcript.worktree != "/legacy/wt" { - t.Fatalf("transcript = %+v", m.transcript) - } -} - -func TestMapOrchestrator_DoubleDecodedLifecycle(t *testing.T) { - // lifecycle stored as a JSON-encoded string (legacy double-encoding). - raw := map[string]any{ - "agent": "codex", - "worktree": "/wt", - "createdAt": "2026-01-01T00:00:00Z", - "lifecycle": `{"session":{"state":"needs_input","lastTransitionAt":"2026-01-01T02:00:00Z"}}`, - "codexThreadId": "thread-9", - "codexModel": "o3", - } - m := mapOrchestratorRecord(raw, "p", "pre", mtime()) - if m.status != orchMapped { - t.Fatalf("status = %s", m.status) - } - if m.record.Activity.State != domain.ActivityWaitingInput { - t.Fatalf("state = %s, want waiting_input", m.record.Activity.State) - } - if m.record.Metadata.AgentSessionID != "thread-9" { - t.Fatalf("agentSessionID = %q, want thread-9", m.record.Metadata.AgentSessionID) - } - if m.transcript != nil { - t.Fatal("codex must not carry a transcript relocation") - } - if m.note == "" { - t.Fatal("expected a note about dropped codexModel") - } -} - -func TestMapOrchestrator_StatePayloadFallback(t *testing.T) { - raw := map[string]any{ - "agent": "opencode", - "stateVersion": "2", - "statePayload": map[string]any{"session": map[string]any{"state": "idle"}}, - "opencodeSessionId": "oc-1", - } - m := mapOrchestratorRecord(raw, "p", "p", mtime()) - if m.status != orchMapped || m.record.Activity.State != domain.ActivityIdle { - t.Fatalf("mapping = %+v", m) - } - if m.record.Metadata.AgentSessionID != "oc-1" { - t.Fatalf("agentSessionID = %q", m.record.Metadata.AgentSessionID) - } -} - -func TestMapOrchestrator_StatePayloadFallbackNumericVersion(t *testing.T) { - // stateVersion as a JSON number (decodes to float64) must still trigger the - // statePayload fallback. - raw := map[string]any{ - "agent": "opencode", - "stateVersion": float64(2), - "statePayload": map[string]any{"session": map[string]any{"state": "needs_input"}}, - "opencodeSessionId": "oc-2", - } - m := mapOrchestratorRecord(raw, "p", "p", mtime()) - if m.status != orchMapped || m.record.Activity.State != domain.ActivityWaitingInput { - t.Fatalf("mapping = %+v", m) - } -} - -func TestMapOrchestrator_SkipTerminal(t *testing.T) { - for _, st := range []string{"done", "terminated"} { - raw := map[string]any{ - "agent": "claude-code", - "lifecycle": map[string]any{"session": map[string]any{"state": st}}, - } - if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped { - t.Fatalf("state %s: status = %s, want skipped", st, m.status) - } - } -} - -func TestMapOrchestrator_SkipTerminatedAt(t *testing.T) { - raw := map[string]any{ - "agent": "claude-code", - "lifecycle": map[string]any{"session": map[string]any{ - "state": "working", "terminatedAt": "2026-01-01T00:00:00Z", - }}, - } - if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped { - t.Fatalf("status = %s, want skipped (terminatedAt set)", m.status) - } -} - -func TestMapOrchestrator_SkipAiderAndUnknown(t *testing.T) { - for _, agent := range []string{"aider", "grok", "", "bogus"} { - raw := map[string]any{ - "agent": agent, - "lifecycle": map[string]any{"session": map[string]any{"state": "working"}}, - } - if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped { - t.Fatalf("agent %q: status = %s, want skipped", agent, m.status) - } - } -} - -func TestMapOrchestrator_TimestampFallbacks(t *testing.T) { - // No createdAt/startedAt → file mtime; no lastTransitionAt → createdAt. - raw := map[string]any{ - "agent": "claude-code", - "lifecycle": map[string]any{"session": map[string]any{"state": "idle"}}, - } - m := mapOrchestratorRecord(raw, "p", "p", mtime()) - if !m.record.CreatedAt.Equal(mtime()) { - t.Fatalf("createdAt = %s, want file mtime", m.record.CreatedAt) - } - if !m.record.Activity.LastActivityAt.Equal(mtime()) { - t.Fatalf("activityLastAt = %s, want createdAt fallback", m.record.Activity.LastActivityAt) - } -} - -func TestResolveOrchestratorPrefix(t *testing.T) { - if got := resolveOrchestratorPrefix("short", legacyProjectConfig{}); got != "short" { - t.Fatalf("prefix = %q, want short", got) - } - if got := resolveOrchestratorPrefix("averylongprojectid", legacyProjectConfig{}); got != "averylongpro" { - t.Fatalf("prefix = %q, want first 12 chars", got) - } - if got := resolveOrchestratorPrefix("proj", legacyProjectConfig{SessionPrefix: "custom"}); got != "custom" { - t.Fatalf("prefix = %q, want custom", got) - } -} diff --git a/backend/internal/legacyimport/paths.go b/backend/internal/legacyimport/paths.go index f1150ead5..7c9b6ba0d 100644 --- a/backend/internal/legacyimport/paths.go +++ b/backend/internal/legacyimport/paths.go @@ -1,8 +1,6 @@ // Package legacyimport reads the legacy Agent Orchestrator flat-file store // (~/.agent-orchestrator) read-only and ports it into the rewrite's native -// SQLite store. It maps the legacy project registry, per-project settings, and -// each project's single live orchestrator session, relocating the orchestrator's -// Claude transcript so a claude-code orchestrator resumes with context. +// SQLite store. It maps the legacy project registry and per-project settings. // // This is the Go port of the legacy-side TypeScript reader (AgentWrapper PR // #2144 / issue #2129); the field mapping is ReverbCode issue #247. The legacy @@ -13,7 +11,6 @@ package legacyimport import ( "os" "path/filepath" - "strings" ) // userHomeDir is indirected so tests can pin the home directory without mutating @@ -30,16 +27,6 @@ func DefaultLegacyRootDir() string { return filepath.Join(home, ".agent-orchestrator") } -// defaultClaudeProjectsDir returns ~/.claude/projects, the directory Claude Code -// buckets per-cwd transcripts under. "" when home cannot be resolved. -func defaultClaudeProjectsDir() string { - home, err := userHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".claude", "projects") -} - // globalConfigPath is the legacy global config file, root/config.yaml. func globalConfigPath(root string) string { return filepath.Join(root, "config.yaml") @@ -54,42 +41,3 @@ func preferencesPath(root string) string { func registeredPath(root string) string { return filepath.Join(root, "portfolio", "registered.json") } - -// projectSessionsDir locates a project's sessions directory, accepting both the -// current layout (root/projects/{id}/sessions) and the older hashed layout -// (root/{hash}-{id}/sessions). It returns "" when neither exists. -func projectSessionsDir(root, projectID string) string { - primary := filepath.Join(root, "projects", projectID, "sessions") - if isDir(primary) { - return primary - } - // Older layout: a top-level "{hash}-{id}" directory. Match by the "-{id}" - // suffix; the id itself may contain "-", but the hashed form always prefixes - // it, so a suffix match is the faithful locator the legacy reader used. - entries, err := os.ReadDir(root) - if err != nil { - return "" - } - suffix := "-" + projectID - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() - if name == "projects" || name == "portfolio" { - continue - } - if strings.HasSuffix(name, suffix) { - cand := filepath.Join(root, name, "sessions") - if isDir(cand) { - return cand - } - } - } - return "" -} - -func isDir(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} diff --git a/backend/internal/legacyimport/project_test.go b/backend/internal/legacyimport/project_test.go index 48dee926a..ae69fca68 100644 --- a/backend/internal/legacyimport/project_test.go +++ b/backend/internal/legacyimport/project_test.go @@ -5,8 +5,15 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + yaml "gopkg.in/yaml.v3" ) +// nonNilNode returns a non-nil *yaml.Node for struct fields that are captured +// as raw nodes (tracker, scm, etc.), used to trigger the "dropped" note path. +func nonNilNode() *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Value: "x"} +} + func TestMapPermission(t *testing.T) { cases := []struct { in string diff --git a/backend/internal/service/importer/importer.go b/backend/internal/service/importer/importer.go new file mode 100644 index 000000000..57427d845 --- /dev/null +++ b/backend/internal/service/importer/importer.go @@ -0,0 +1,67 @@ +// Package importer is the controller-facing service for the legacy-AO import. +// It wraps the internal/legacyimport engine with a detection probe (is a legacy +// install present?) and a trigger that runs the import through the live daemon's +// store, so the daemon stays the sole writer. Whether to PROMPT for the import +// is the desktop app's job (the app-state.json migration marker), so this probe +// reports only physical availability, not "already imported". +package importer + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" +) + +// Store is the storage slice the import runs through; *sqlite.Store satisfies it. +type Store interface { + legacyimport.Store +} + +// Status reports whether a legacy AO install is physically present to import. +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. +type Manager struct { + store Store + root string +} + +var _ Service = (*Manager)(nil) + +// New constructs the import service. An empty Root falls back to the default. +func New(deps Deps) *Manager { + root := deps.Root + if root == "" { + root = legacyimport.DefaultLegacyRootDir() + } + return &Manager{store: deps.Store, root: root} +} + +// Status reports availability only: legacy data present at the root. It never +// errors on a missing legacy store; that is simply "not available". +func (m *Manager) Status(_ context.Context) (Status, error) { + return Status{Available: legacyimport.HasLegacyData(m.root), LegacyRoot: m.root}, nil +} + +// Run executes the import through the daemon's store. Idempotent: the engine +// skips rows that already exist. 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}) +} diff --git a/backend/internal/service/importer/importer_test.go b/backend/internal/service/importer/importer_test.go new file mode 100644 index 000000000..8c4fa33c7 --- /dev/null +++ b/backend/internal/service/importer/importer_test.go @@ -0,0 +1,76 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type fakeStore struct{ projects map[string]domain.ProjectRecord } + +func newFakeStore() *fakeStore { return &fakeStore{projects: map[string]domain.ProjectRecord{}} } +func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { + r, ok := f.projects[id] + return r, ok, nil +} +func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) error { + f.projects[r.ID] = r + return nil +} + +func writeLegacyRoot(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + if err := os.MkdirAll(filepath.Join(root, "projects"), 0o750); err != nil { + t.Fatal(err) + } + cfg := "projects:\n alpha:\n path: /repos/alpha\n name: Alpha\n" + if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + return root +} + +func TestStatus_NoLegacyData(t *testing.T) { + svc := New(Deps{Store: newFakeStore(), Root: filepath.Join(t.TempDir(), "nope")}) + st, err := svc.Status(context.Background()) + if err != nil || st.Available { + t.Fatalf("want unavailable; got %+v err=%v", st, err) + } +} + +func TestStatus_LegacyPresentStaysAvailableAfterImport(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + st, err := svc.Status(context.Background()) + if err != nil || !st.Available || st.LegacyRoot != root { + t.Fatalf("want available at %q; got %+v err=%v", root, st, err) + } + if _, err := svc.Run(context.Background()); err != nil { + t.Fatalf("run: %v", err) + } + // Availability is physical (legacy data still on disk), so it stays true; the + // app marker is what stops the prompt after a completed import. + st, _ = svc.Status(context.Background()) + if !st.Available { + t.Fatal("availability must remain true after import (marker governs prompting)") + } +} + +func TestRun_ImportsProjects(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + rep, err := svc.Run(context.Background()) + if err != nil || rep.ProjectsImported != 1 { + t.Fatalf("projectsImported=%d err=%v", rep.ProjectsImported, err) + } +} + +func TestNew_DefaultsRoot(t *testing.T) { + if New(Deps{Store: newFakeStore()}).root == "" { + t.Fatal("empty Root should fall back to the default legacy root") + } +} diff --git a/backend/internal/storage/sqlite/store/session_import_store.go b/backend/internal/storage/sqlite/store/session_import_store.go deleted file mode 100644 index a6ef74b6b..000000000 --- a/backend/internal/storage/sqlite/store/session_import_store.go +++ /dev/null @@ -1,60 +0,0 @@ -package store - -import ( - "context" - "fmt" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// ImportSession inserts a session with a caller-supplied id and num, bypassing -// CreateSession's per-project num generation so the legacy importer can preserve -// a verbatim id (e.g. "{prefix}-orchestrator", num 0). It is idempotent: an id -// that already exists is left untouched and inserted=false is returned, so a -// re-run of the importer never clobbers a row the daemon may since have evolved. -// -// Like CreateSession this is a single INSERT under writeMu; the ON CONFLICT -// guard makes the existence check and the insert atomic on the writer -// connection. It uses raw ExecContext to attach the ON CONFLICT clause the -// generated InsertSession query does not carry (the same raw-exec approach -// DeleteSession uses to work around sqlc's DELETE handling). -func (s *Store) ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error) { - activity := normalActivity(rec.Activity, rec.CreatedAt) - s.writeMu.Lock() - defer s.writeMu.Unlock() - res, err := s.writeDB.ExecContext(ctx, ` -INSERT INTO sessions ( - id, project_id, num, issue_id, kind, harness, display_name, - activity_state, activity_last_at, first_signal_at, is_terminated, - branch, workspace_path, runtime_handle_id, agent_session_id, prompt, - created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(id) DO NOTHING`, - rec.ID, - rec.ProjectID, - num, - rec.IssueID, - rec.Kind, - rec.Harness, - rec.DisplayName, - activity.State, - activity.LastActivityAt, - timeToNullTime(rec.FirstSignalAt), - rec.IsTerminated, - rec.Metadata.Branch, - rec.Metadata.WorkspacePath, - rec.Metadata.RuntimeHandleID, - rec.Metadata.AgentSessionID, - rec.Metadata.Prompt, - rec.CreatedAt, - rec.UpdatedAt, - ) - if err != nil { - return false, fmt.Errorf("import session %s: %w", rec.ID, err) - } - n, err := res.RowsAffected() - if err != nil { - return false, fmt.Errorf("import session %s: rows affected: %w", rec.ID, err) - } - return n > 0, nil -} diff --git a/backend/internal/storage/sqlite/store/session_import_store_test.go b/backend/internal/storage/sqlite/store/session_import_store_test.go deleted file mode 100644 index 87c19bd82..000000000 --- a/backend/internal/storage/sqlite/store/session_import_store_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package store_test - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestImportSessionVerbatimAndIdempotent(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - seedProject(t, s, "mer") - - now := time.Now().UTC().Truncate(time.Second) - rec := domain.SessionRecord{ - ID: "mer-orchestrator", - ProjectID: "mer", - Kind: domain.KindOrchestrator, - Harness: domain.HarnessClaudeCode, - Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, - Metadata: domain.SessionMetadata{AgentSessionID: "uuid-1", Prompt: "go"}, - CreatedAt: now, - UpdatedAt: now, - } - - inserted, err := s.ImportSession(ctx, rec, 0) - if err != nil || !inserted { - t.Fatalf("first import: inserted=%v err=%v", inserted, err) - } - - got, ok, err := s.GetSession(ctx, "mer-orchestrator") - if err != nil || !ok { - t.Fatalf("get: ok=%v err=%v", ok, err) - } - if got.Kind != domain.KindOrchestrator || got.Metadata.AgentSessionID != "uuid-1" { - t.Fatalf("imported row = %+v", got) - } - - // Re-import is a no-op: the existing row is left untouched. - inserted, err = s.ImportSession(ctx, rec, 0) - if err != nil { - t.Fatalf("re-import err: %v", err) - } - if inserted { - t.Fatal("re-import reported inserted=true; want false (idempotent skip)") - } - - // num=0 leaves the next store-generated session at num=1 with no collision. - w, err := s.CreateSession(ctx, sampleRecord("mer")) - if err != nil { - t.Fatalf("create worker: %v", err) - } - if w.ID != "mer-1" { - t.Fatalf("worker id = %s, want mer-1 (orchestrator at num 0 must not collide)", w.ID) - } -} 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..dcb29a757 --- /dev/null +++ b/docs/plans/2026-06-26-import-offer.md @@ -0,0 +1,371 @@ +# 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/plans/2026-06-26-legacy-migration-popup.md b/docs/superpowers/plans/2026-06-26-legacy-migration-popup.md new file mode 100644 index 000000000..d2ff54662 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-legacy-migration-popup.md @@ -0,0 +1,706 @@ +# Legacy-Migration Popup + `app-state.json` Marker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On app launch, offer to import legacy AO projects via a dashboard popup gated on a `migration` marker in `~/.ao/app-state.json`; run the import through the app-owned daemon; never touch legacy files. + +**Architecture:** Two layers. **(A) Backend:** a projects-only import daemon API (`GET`/`POST /api/v1/import`). **(B) App:** the desktop app (sole writer of `app-state.json`) stores the migration decision in the marker; a renderer popup reads the marker (over IPC) + the daemon's availability (over HTTP) and offers Proceed / Skip / Don't Migrate. + +**Tech Stack:** Go (chi, code-first OpenAPI via `cmd/genspec`), Electron (main/preload IPC), React + @tanstack/react-query + openapi-fetch, @radix-ui/react-dialog, vitest. + +**Design source:** `docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md`. Backend reuses `docs/plans/2026-06-26-import-offer.md`. + +## Global Constraints + +- Grounded on `upstream/main` @ `514946fd8`. Module path `github.com/aoagents/agent-orchestrator/backend`. +- **No em dashes** in any prose, comment, or UI copy. +- `openapi.yaml` and `frontend/src/api/schema.ts` are **generated**; never hand-edit. Run `npm run api:spec && npm run api:ts`. +- App is the **sole writer** of `~/.ao/app-state.json`; daemon is the **sole writer** of the DB. Marker writes are atomic (temp + rename). +- Import is **projects + per-project settings only** (no orchestrator sessions, no transcripts) and **idempotent**; it **never deletes or modifies** legacy files. +- v1 popup copy must **not** promise a Settings location (Settings deferred to AgentWrapper/agent-orchestrator#2205). +- Branch `ao/agent-orchestrator-3/legacy-migration-popup`; PR target `main` on `AgentWrapper/agent-orchestrator`. Commit after every task (AO worktrees can be force-removed). + +## File Structure + +| File | Status | Responsibility | +| ---------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------- | +| (backend import API) | per import-offer plan | projects-only `legacyimport`, `service/importer`, controller, DTOs, OpenAPI, wiring | +| `backend/internal/service/importer/importer.go` | create (modified Status) | `Status` = `HasLegacyData` only; `Run` | +| `backend/internal/cli/start.go` | modify | drop the `§6.4` TODO comment (`:78`) | +| `frontend/src/main/app-state.ts` | modify | `MigrationState`, schema v2, preserve, `updateMigration`, `readMigrationState` | +| `frontend/src/main/app-state.test.ts` | modify | marker v2 + migration tests | +| `frontend/src/main.ts` | modify | `appState:getMigration` / `appState:setMigration` IPC handlers | +| `frontend/src/preload.ts` | modify | `ao.appState` bridge methods | +| `frontend/src/renderer/lib/bridge.ts` | modify | `appState` preview fallback | +| `frontend/src/renderer/hooks/useMigrationOffer.ts` | create | gate: IPC marker + daemon GET | +| `frontend/src/renderer/components/MigrationPopup.tsx` | create | the three-action dialog | +| `frontend/src/renderer/components/MigrationPopup.test.tsx` | create | popup tests | +| `frontend/src/renderer/routes/_shell.index.tsx` | modify | mount `` on the board | + +--- + +# Part A: Backend import API (projects-only) + +Execute the committed plan `docs/plans/2026-06-26-import-offer.md`, **Tasks 1, 3, 4, 5 only**, with the Task-2 change below. **Skip** that plan's Task 2 as written, Task 6 (start.go is already headless from #2201), and Tasks 7-11 (its frontend is replaced by Part B here). + +- **Task 1** (engine → projects-only): as written. +- **Task 2 REPLACEMENT** (service): see Task A2 below (simpler `Status`). +- **Task 3** (controller + DTOs): as written. +- **Task 4** (OpenAPI regen): as written. After regen, `schema.ts` must contain `/api/v1/import`, `ImportStatusResponse {available, legacyRoot}`, `ImportRunResponse {report}`. +- **Task 5** (wire api.go + daemon.go): as written, i.e. `Import: importsvc.New(importsvc.Deps{Store: store})`. + +### Task A2: Import service with availability-only status + +**Files:** Create `backend/internal/service/importer/importer.go`, `…/importer_test.go` + +**Interfaces:** + +- Produces: `importer.Status{Available bool; LegacyRoot string}`, `importer.Service` (`Status(ctx)`, `Run(ctx)`), `importer.Deps{Store, Root}`, `importer.New(Deps) *Manager`. `Store` is exactly `legacyimport.Store` (the projects-only `GetProject`/`UpsertProject`) — **no `ListProjects`** (the design drops the empty-DB heuristic; the app marker governs prompting). + +- [ ] **Step 1: Write `importer.go`:** + +```go +// Package importer is the controller-facing service for the legacy-AO import. +// It wraps the internal/legacyimport engine with a detection probe (is a legacy +// install present?) and a trigger that runs the import through the live daemon's +// store, so the daemon stays the sole writer. Whether to PROMPT for the import +// is the desktop app's job (the app-state.json migration marker), so this probe +// reports only physical availability, not "already imported". +package importer + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" +) + +// Store is the storage slice the import runs through; *sqlite.Store satisfies it. +type Store interface { + legacyimport.Store +} + +// Status reports whether a legacy AO install is physically present to import. +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. +type Manager struct { + store Store + root string +} + +var _ Service = (*Manager)(nil) + +// New constructs the import service. An empty Root falls back to the default. +func New(deps Deps) *Manager { + root := deps.Root + if root == "" { + root = legacyimport.DefaultLegacyRootDir() + } + return &Manager{store: deps.Store, root: root} +} + +// Status reports availability only: legacy data present at the root. It never +// errors on a missing legacy store; that is simply "not available". +func (m *Manager) Status(_ context.Context) (Status, error) { + return Status{Available: legacyimport.HasLegacyData(m.root), LegacyRoot: m.root}, nil +} + +// Run executes the import through the daemon's store. Idempotent: the engine +// skips rows that already exist. 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`:** + +```go +package importer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type fakeStore struct{ projects map[string]domain.ProjectRecord } + +func newFakeStore() *fakeStore { return &fakeStore{projects: map[string]domain.ProjectRecord{}} } +func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { + r, ok := f.projects[id] + return r, ok, nil +} +func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) error { + f.projects[r.ID] = r + return nil +} + +func writeLegacyRoot(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + if err := os.MkdirAll(filepath.Join(root, "projects"), 0o750); err != nil { + t.Fatal(err) + } + cfg := "projects:\n alpha:\n path: /repos/alpha\n name: Alpha\n" + if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + return root +} + +func TestStatus_NoLegacyData(t *testing.T) { + svc := New(Deps{Store: newFakeStore(), Root: filepath.Join(t.TempDir(), "nope")}) + st, err := svc.Status(context.Background()) + if err != nil || st.Available { + t.Fatalf("want unavailable; got %+v err=%v", st, err) + } +} + +func TestStatus_LegacyPresentStaysAvailableAfterImport(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + st, err := svc.Status(context.Background()) + if err != nil || !st.Available || st.LegacyRoot != root { + t.Fatalf("want available at %q; got %+v err=%v", root, st, err) + } + if _, err := svc.Run(context.Background()); err != nil { + t.Fatalf("run: %v", err) + } + // Availability is physical (legacy data still on disk), so it stays true; the + // app marker is what stops the prompt after a completed import. + st, _ = svc.Status(context.Background()) + if !st.Available { + t.Fatal("availability must remain true after import (marker governs prompting)") + } +} + +func TestRun_ImportsProjects(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + rep, err := svc.Run(context.Background()) + if err != nil || rep.ProjectsImported != 1 { + t.Fatalf("projectsImported=%d err=%v", rep.ProjectsImported, err) + } +} + +func TestNew_DefaultsRoot(t *testing.T) { + if New(Deps{Store: newFakeStore()}).root == "" { + t.Fatal("empty Root should fall back to the default legacy root") + } +} +``` + +- [ ] **Step 3:** `cd backend && go test ./internal/service/importer/...` → expect PASS. +- [ ] **Step 4:** Commit: `feat(importer): availability probe + projects-only run` + +### Task A6: Clear the resolved TODO in start.go + +**Files:** Modify `backend/internal/cli/start.go` + +- [ ] **Step 1:** Delete the now-resolved comment at `start.go:78` (`// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; …`). The behavior it describes is implemented by this feature. +- [ ] **Step 2:** `cd backend && go build ./...` → expect PASS. +- [ ] **Step 3:** Commit: `chore(cli): drop resolved §6.4 first-boot-import TODO` + +--- + +# Part B: App-side marker + popup + +### Task B1: Migration marker in `app-state.json` (schema v2) + +**Files:** Modify `frontend/src/main/app-state.ts`, `frontend/src/main/app-state.test.ts` + +**Interfaces:** + +- Produces: `MigrationStatus`, `MigrationState`, `updateMigration({stateDir, migration, now})`, `readMigrationState(stateDir) => Promise`. `AppStateMarker` gains `migration?: MigrationState`. `SCHEMA_VERSION === 2`. **B2/B3/B4 depend on these names.** + +- [ ] **Step 1 (TDD): add tests to `app-state.test.ts`** (follows the file's existing temp-dir style): + +```ts +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { APP_STATE_FILE_NAME, readMigrationState, updateMigration, writeAppStateMarker } from "./app-state"; + +const fixedNow = () => new Date("2026-06-26T10:00:00.000Z"); +async function tmp() { + return mkdtemp(path.join(os.tmpdir(), "ao-appstate-")); +} + +describe("migration marker", () => { + it("readMigrationState defaults to pending when the file is absent", async () => { + expect(await readMigrationState(await tmp())).toEqual({ status: "pending" }); + }); + + it("updateMigration persists status without an existing marker", async () => { + const dir = await tmp(); + await updateMigration({ stateDir: dir, migration: { status: "declined" }, now: fixedNow }); + expect((await readMigrationState(dir)).status).toBe("declined"); + }); + + it("a launch write preserves an existing migration block", async () => { + const dir = await tmp(); + await updateMigration({ stateDir: dir, migration: { status: "completed" }, now: fixedNow }); + await writeAppStateMarker({ stateDir: dir, appPath: "/A.app", version: "1.2.3", now: fixedNow }); + const raw = JSON.parse(await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8")); + expect(raw.schemaVersion).toBe(2); + expect(raw.appPath).toBe("/A.app"); + expect(raw.migration.status).toBe("completed"); + }); + + it("updateMigration does not clobber launch fields", async () => { + const dir = await tmp(); + await writeAppStateMarker({ stateDir: dir, appPath: "/A.app", version: "1.2.3", now: fixedNow }); + await updateMigration({ stateDir: dir, migration: { status: "failed", error: "x" }, now: fixedNow }); + const raw = JSON.parse(await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8")); + expect(raw.appPath).toBe("/A.app"); + expect(raw.migration).toEqual({ status: "failed", error: "x" }); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** (`updateMigration`/`readMigrationState` not exported): `npm --prefix frontend run test -- app-state`. +- [ ] **Step 3: Edit `app-state.ts`.** Set `const SCHEMA_VERSION = 2;`. Add types and the marker field: + +```ts +export type MigrationStatus = "pending" | "completed" | "declined" | "failed"; + +export interface MigrationState { + status: MigrationStatus; + lastAttemptAt?: string; + completedAt?: string; + report?: { projectsImported: number; projectsSkipped: number }; + error?: string; +} +``` + +Add `migration?: MigrationState;` to `AppStateMarker`. Extract the existing temp+rename into a helper and reuse it: + +```ts +async function atomicWriteMarker(stateDir: string, marker: AppStateMarker): Promise { + await mkdir(stateDir, { recursive: true, mode: 0o750 }); + const file = path.join(stateDir, APP_STATE_FILE_NAME); + const data = `${JSON.stringify(marker, null, 2)}\n`; + const tmp = path.join(stateDir, `.app-state-${process.pid}-${Date.now()}.json`); + await writeFile(tmp, data, { mode: 0o600 }); + await rename(tmp, file); +} +``` + +In `writeAppStateMarker`, build the `marker` object as today but add `migration: existing?.migration` (preserve), and replace the inline temp+rename with `await atomicWriteMarker(opts.stateDir, marker);`. Then add: + +```ts +export interface UpdateMigrationOptions { + stateDir: string; + migration: MigrationState; + now: () => Date; +} + +// updateMigration sets ONLY the migration block, preserving every launch-written +// field already on disk. Used by the app's IPC setter. Atomic like the launch write. +export async function updateMigration(opts: UpdateMigrationOptions): Promise { + const file = path.join(opts.stateDir, APP_STATE_FILE_NAME); + const existing = await readExisting(file); + const nowIso = opts.now().toISOString(); + const marker: AppStateMarker = existing + ? { ...existing, migration: opts.migration } + : { + schemaVersion: SCHEMA_VERSION, + appPath: "", + version: "", + installedAt: nowIso, + lastReconciledAt: nowIso, + installSource: "unknown", + migration: opts.migration, + }; + await atomicWriteMarker(opts.stateDir, marker); +} + +// readMigrationState returns the marker's migration block, defaulting to pending +// when the file is absent or unparseable (self-healing, like the rest of the reader). +export async function readMigrationState(stateDir: string): Promise { + const existing = await readExisting(path.join(stateDir, APP_STATE_FILE_NAME)); + return existing?.migration ?? { status: "pending" }; +} +``` + +- [ ] **Step 4: Run → PASS**: `npm --prefix frontend run test -- app-state`. +- [ ] **Step 5:** Commit: `feat(app-state): migration marker (schema v2) + updateMigration` + +### Task B2: IPC handlers + preload/bridge + +**Files:** Modify `frontend/src/main.ts`, `frontend/src/preload.ts`, `frontend/src/renderer/lib/bridge.ts` + +**Interfaces:** Produces `window.ao.appState.getMigration()` / `setMigration(m)` (typed via `AoBridge`). **B3/B4 consume these.** + +- [ ] **Step 1: `main.ts`** — extend the marker import and add two handlers next to the other `ipcMain.handle` calls (`:773-796`): + +```ts +import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state"; +``` + +```ts +ipcMain.handle("appState:getMigration", async (): Promise => { + const runFile = runFilePath(); + if (!runFile) return { status: "pending" }; + return readMigrationState(path.dirname(runFile)); +}); +ipcMain.handle("appState:setMigration", async (_event, migration: MigrationState) => { + const runFile = runFilePath(); + if (!runFile) return; + await updateMigration({ stateDir: path.dirname(runFile), migration, now: () => new Date() }); +}); +``` + +- [ ] **Step 2: `preload.ts`** — import the type and add to `api`: + +```ts +import type { MigrationState } from "./main/app-state"; +``` + +```ts + appState: { + getMigration: () => ipcRenderer.invoke("appState:getMigration") as Promise, + setMigration: (migration: MigrationState) => + ipcRenderer.invoke("appState:setMigration", migration) as Promise, + }, +``` + +- [ ] **Step 3: `bridge.ts`** — add the preview fallback so `AoBridge` stays satisfied: + +```ts + appState: { + getMigration: async () => ({ status: "pending" }), + setMigration: async () => undefined, + }, +``` + +- [ ] **Step 4:** `npm --prefix frontend run typecheck` → expect PASS. +- [ ] **Step 5:** Commit: `feat(ipc): expose app-state migration getter/setter to the renderer` + +### Task B3: `useMigrationOffer` gate hook + +**Files:** Create `frontend/src/renderer/hooks/useMigrationOffer.ts` + +**Interfaces:** Consumes `apiClient` (`renderer/lib/api-client`, needs the Part A `schema.ts` paths), `aoBridge` (`renderer/lib/bridge`), `MigrationState` (`main/app-state`). Produces `useMigrationOffer()`, `migrationOfferQueryKey`, `MigrationOffer`. + +- [ ] **Step 1: Create the file:** + +```ts +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "../lib/api-client"; +import { aoBridge } from "../lib/bridge"; +import type { MigrationState } from "../../main/app-state"; + +export const migrationOfferQueryKey = ["migration-offer"] as const; +const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1"; + +export interface MigrationOffer { + show: boolean; + legacyRoot: string; + migration: MigrationState; +} + +// fetchMigrationOffer combines the app marker (decision) with the daemon's +// availability (is there legacy data). A terminal marker (completed/declined) +// short-circuits before any daemon call. A 501/unreachable daemon resolves to +// "no offer", never an error. +async function fetchMigrationOffer(): Promise { + const migration = await aoBridge.appState.getMigration(); + if (migration.status === "completed" || migration.status === "declined") { + return { show: false, legacyRoot: "", migration }; + } + const { data, error } = await apiClient.GET("/api/v1/import"); + const legacyRoot = data?.legacyRoot ?? ""; + if (error || !data?.available) return { show: false, legacyRoot, migration }; + return { show: true, legacyRoot, migration }; +} + +export function useMigrationOffer() { + return useQuery({ + queryKey: migrationOfferQueryKey, + queryFn: fetchMigrationOffer, + enabled: !usePreviewData, + retry: 1, + throwOnError: false, + }); +} +``` + +- [ ] **Step 2:** `npm --prefix frontend run typecheck` → expect PASS. +- [ ] **Step 3:** Commit: `feat(renderer): useMigrationOffer gate (marker + availability)` + +### Task B4: `MigrationPopup` component + +**Files:** Create `frontend/src/renderer/components/MigrationPopup.tsx`, `…/MigrationPopup.test.tsx` + +**Interfaces:** Consumes `useMigrationOffer`/`migrationOfferQueryKey`, `workspaceQueryKey`, `apiClient`/`apiErrorMessage`, `aoBridge`. Self-contained (no props). + +- [ ] **Step 1 (TDD): Create `MigrationPopup.test.tsx`:** + +```tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MigrationPopup } from "./MigrationPopup"; + +const { getMock, postMock, getMigration, setMigration } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + getMigration: vi.fn(), + setMigration: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock, POST: postMock }, + apiErrorMessage: (e: unknown, fb = "Request failed") => + e instanceof Error ? e.message : ((e as { message?: string })?.message ?? fb), +})); +vi.mock("../lib/bridge", () => ({ aoBridge: { appState: { getMigration, setMigration } } })); + +function renderPopup() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return qc; +} + +beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + getMigration.mockReset(); + setMigration.mockReset(); + getMigration.mockResolvedValue({ status: "pending" }); + getMock.mockResolvedValue({ data: { available: true, legacyRoot: "/home/u/.agent-orchestrator" }, error: undefined }); + postMock.mockResolvedValue({ data: { report: { projectsImported: 2, projectsSkipped: 1 } }, error: undefined }); + setMigration.mockResolvedValue(undefined); +}); + +describe("MigrationPopup", () => { + it("shows when a legacy install is available and the marker is pending", async () => { + renderPopup(); + expect(await screen.findByText(/Import projects from your earlier AO/i)).toBeInTheDocument(); + expect(screen.getByText("/home/u/.agent-orchestrator")).toBeInTheDocument(); + }); + + it("renders nothing when the marker is declined", async () => { + getMigration.mockResolvedValue({ status: "declined" }); + renderPopup(); + await waitFor(() => expect(getMigration).toHaveBeenCalled()); + expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument(); + expect(getMock).not.toHaveBeenCalled(); + }); + + it("Proceed imports, marks completed, and retires", async () => { + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Proceed" })); + await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import")); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "completed" })); + await waitFor(() => expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument()); + }); + + it("Don't Migrate records declined", async () => { + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Don't Migrate" })); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "declined" })); + }); + + it("Skip dismisses without writing the marker", async () => { + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Skip" })); + expect(setMigration).not.toHaveBeenCalled(); + expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument(); + }); + + it("a failed import shows the lossless reassurance and marks failed", async () => { + postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } }); + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Proceed" })); + expect(await screen.findByText(/nothing is ever deleted/i)).toBeInTheDocument(); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", error: "disk full" })); + }); +}); +``` + +- [ ] **Step 2: Run → FAIL** (component missing): `npm --prefix frontend run test -- MigrationPopup`. +- [ ] **Step 3: Create `MigrationPopup.tsx`:** + +```tsx +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "./ui/button"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { aoBridge } from "../lib/bridge"; +import { migrationOfferQueryKey, useMigrationOffer } from "../hooks/useMigrationOffer"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; + +// MigrationPopup is the first-run legacy-AO import offer. It shows only when the +// app marker is non-terminal (pending/failed) AND the daemon reports legacy data +// available. Proceed runs the idempotent import through the daemon; Skip dismisses +// for this launch (re-prompts next launch); Don't Migrate declines permanently +// (re-runnable later once the Settings entry point lands, issue #2205). +export function MigrationPopup() { + const offer = useMigrationOffer(); + const queryClient = useQueryClient(); + const [skipped, setSkipped] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const open = (offer.data?.show ?? false) && !skipped; + if (!open) return null; + + const legacyRoot = offer.data?.legacyRoot || "your earlier AO"; + const nowIso = () => new Date().toISOString(); + + const proceed = async () => { + setBusy(true); + setError(undefined); + const { data, error: apiErr } = await apiClient.POST("/api/v1/import"); + if (apiErr) { + const msg = apiErrorMessage(apiErr); + setError(msg); + await aoBridge.appState.setMigration({ status: "failed", lastAttemptAt: nowIso(), error: msg }); + setBusy(false); + return; + } + const report = data?.report; + await aoBridge.appState.setMigration({ + status: "completed", + lastAttemptAt: nowIso(), + completedAt: nowIso(), + report: report + ? { projectsImported: report.projectsImported, projectsSkipped: report.projectsSkipped } + : undefined, + }); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey }); + setBusy(false); + }; + + const dontMigrate = async () => { + await aoBridge.appState.setMigration({ status: "declined", lastAttemptAt: nowIso() }); + await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey }); + }; + + return ( + { + if (!next) setSkipped(true); + }} + > + + + + + Import projects from your earlier AO? + + + We found an existing install at {legacyRoot}. + Importing brings in your projects. Your old files are never modified, and you can do this later. + + {error && ( +
+ Migration failed: {error}. Your legacy projects are untouched (nothing is ever deleted). You can retry. +
+ )} +

You can run this again later.

+
+ +
+ + +
+
+
+
+
+ ); +} +``` + +- [ ] **Step 4: Run → PASS**: `npm --prefix frontend run test -- MigrationPopup`. +- [ ] **Step 5:** Commit: `feat(renderer): MigrationPopup (Proceed / Skip / Don't Migrate)` + +### Task B5: Mount on the dashboard + +**Files:** Modify `frontend/src/renderer/routes/_shell.index.tsx` + +- [ ] **Step 1:** Render the popup alongside the board: + +```tsx +import { createFileRoute } from "@tanstack/react-router"; +import { MigrationPopup } from "../components/MigrationPopup"; +import { SessionsBoard } from "../components/SessionsBoard"; + +export const Route = createFileRoute("/_shell/")({ + component: () => ( + <> + + + + ), +}); +``` + +- [ ] **Step 2:** `npm --prefix frontend run typecheck && npm --prefix frontend run test -- MigrationPopup` → expect PASS. +- [ ] **Step 3:** Commit: `feat(renderer): surface MigrationPopup on the dashboard` + +### Task B6: 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. +- [ ] `git status` shows no uncommitted drift after `npm run api:spec && npm run api:ts`. +- [ ] **Full frontend production build** (rollup tree-shaking can hide missing emits). +- [ ] Manual: seed `~/.agent-orchestrator` with a legacy `config.yaml`, empty rewrite DB, launch the app → popup appears. Proceed → projects appear, `~/.ao/app-state.json` shows `migration.status: "completed"`, relaunch → no popup. Separately test Skip (re-prompts next launch) and Don't Migrate (`declined`, no re-prompt). + +--- + +## Self-Review + +**Spec coverage:** ✅ import daemon API projects-only (Part A); availability-only Status per design §5.2 (A2); marker schema v2 + preserve + updateMigration (B1); IPC getter/setter + bridge (B2); gate combining marker + availability (B3); popup with Proceed/Skip/Don't Migrate + lossless failure copy (B4); mount on board (B5); resolved-TODO cleanup (A6). Settings redo path correctly **excluded** (deferred to #2205); v1 copy avoids any Settings promise. + +**Placeholder scan:** none — every new file has complete source; Part A references a committed, complete plan for its unchanged tasks and gives full code for the one changed task. + +**Type consistency:** `MigrationState`/`MigrationStatus` defined in B1 are imported unchanged by B2 (preload/main/bridge), B3 (hook), B4 (popup). `updateMigration({stateDir, migration, now})` signature matches its B2 call. `migrationOfferQueryKey`/`workspaceQueryKey` consistent B3↔B4. `Status{Available, LegacyRoot}` (A2) ↔ `ImportStatusResponse` (Part A Task 3) ↔ `schema.ts` GET shape used in B3. `report.{projectsImported,projectsSkipped}` (engine, Part A Task 1) ↔ B4 usage. + +**Cross-task coupling:** B3 needs Part A's regenerated `schema.ts` (Task 4) for `apiClient.GET/POST("/api/v1/import")` to typecheck, so **Part A precedes Part B**. B2's bridge fallback must be added or B3/B4 typecheck fails under `AoBridge`. 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..2579d5177 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md @@ -0,0 +1,269 @@ +# 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. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index a7f68302d..ff6d8c967 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -21,6 +21,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check whether a legacy AO install is available to import */ + get: operations["getImportStatus"]; + put?: never; + /** Run the legacy AO project import through the daemon store */ + post: operations["runImport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/notifications": { parameters: { query?: never; @@ -551,6 +569,19 @@ export interface components { DomainReviewerConfig: { harness: string; }; + ImportReport: { + dryRun: boolean; + notes?: string[]; + projectsImported: number; + projectsSkipped: number; + }; + ImportRunResponse: { + report: components["schemas"]["ImportReport"]; + }; + ImportStatusResponse: { + available: boolean; + legacyRoot: string; + }; KillSessionResponse: { freed?: boolean; ok: boolean; @@ -918,6 +949,82 @@ export interface operations { }; }; }; + getImportStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportStatusResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + runImport: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImportRunResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; listNotifications: { parameters: { query?: { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c77f9ac1e..350dffd89 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -35,7 +35,7 @@ import { buildTelemetryBootstrap } from "./shared/telemetry"; import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host"; import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link"; import { shouldLinkOnAttach } from "./main/daemon-owner"; -import { writeAppStateMarker } from "./main/app-state"; +import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -795,6 +795,17 @@ ipcMain.handle("clipboard:writeText", (_event, text: string) => { }); ipcMain.handle("clipboard:readText", () => clipboard.readText()); +ipcMain.handle("appState:getMigration", async (): Promise => { + const runFile = runFilePath(); + if (!runFile) return { status: "pending" }; + return readMigrationState(path.dirname(runFile)); +}); +ipcMain.handle("appState:setMigration", async (_event, migration: MigrationState) => { + const runFile = runFilePath(); + if (!runFile) return; + await updateMigration({ stateDir: path.dirname(runFile), migration, now: () => new Date() }); +}); + ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => { if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return; const toast = new ElectronNotification({ diff --git a/frontend/src/main/app-state.test.ts b/frontend/src/main/app-state.test.ts index 5b6b6b018..2fd6ebc56 100644 --- a/frontend/src/main/app-state.test.ts +++ b/frontend/src/main/app-state.test.ts @@ -1,9 +1,15 @@ // @vitest-environment node import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { APP_STATE_FILE_NAME, writeAppStateMarker, type AppStateMarker } from "./app-state"; +import { + APP_STATE_FILE_NAME, + writeAppStateMarker, + readMigrationState, + updateMigration, + type AppStateMarker, +} from "./app-state"; // The exact key set the Go reader (start.go `appState`) unmarshals. const GO_READER_KEYS = ["schemaVersion", "appPath", "version", "installedAt", "lastReconciledAt", "installSource"]; @@ -35,7 +41,7 @@ describe("writeAppStateMarker", () => { }); const m = await readMarker(dir); - expect(m.schemaVersion).toBe(1); + expect(m.schemaVersion).toBe(2); expect(m.appPath).toBe("/Applications/Agent Orchestrator.app"); expect(m.version).toBe("0.0.0"); expect(m.installedAt).toBe("2026-06-26T10:00:00.000Z"); @@ -126,3 +132,55 @@ describe("writeAppStateMarker", () => { expect(m.appPath).toBe("/Applications/Agent Orchestrator.app"); }); }); + +// ---- migration marker tests (B1) ---- + +const fixedNow = () => new Date("2026-06-26T10:00:00.000Z"); + +describe("migration marker", () => { + const dirs: string[] = []; + async function tmp() { + const dir = await mkdtemp(path.join(os.tmpdir(), "ao-appstate-")); + dirs.push(dir); + return dir; + } + + afterEach(async () => { + await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); + }); + + it("readMigrationState defaults to pending when the file is absent", async () => { + expect(await readMigrationState(await tmp())).toEqual({ status: "pending" }); + }); + + it("readMigrationState defaults to pending when the file is corrupt", async () => { + const dir = await tmp(); + await writeFile(path.join(dir, APP_STATE_FILE_NAME), "{ not valid json", "utf8"); + expect(await readMigrationState(dir)).toEqual({ status: "pending" }); + }); + + it("updateMigration persists status without an existing marker", async () => { + const dir = await tmp(); + await updateMigration({ stateDir: dir, migration: { status: "declined" }, now: fixedNow }); + expect((await readMigrationState(dir)).status).toBe("declined"); + }); + + it("a launch write preserves an existing migration block", async () => { + const dir = await tmp(); + await updateMigration({ stateDir: dir, migration: { status: "completed" }, now: fixedNow }); + await writeAppStateMarker({ stateDir: dir, appPath: "/A.app", version: "1.2.3", now: fixedNow }); + const raw = JSON.parse(await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8")); + expect(raw.schemaVersion).toBe(2); + expect(raw.appPath).toBe("/A.app"); + expect(raw.migration.status).toBe("completed"); + }); + + it("updateMigration does not clobber launch fields", async () => { + const dir = await tmp(); + await writeAppStateMarker({ stateDir: dir, appPath: "/A.app", version: "1.2.3", now: fixedNow }); + await updateMigration({ stateDir: dir, migration: { status: "failed", error: "x" }, now: fixedNow }); + const raw = JSON.parse(await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8")); + expect(raw.appPath).toBe("/A.app"); + expect(raw.migration).toEqual({ status: "failed", error: "x" }); + }); +}); diff --git a/frontend/src/main/app-state.ts b/frontend/src/main/app-state.ts index ab35a26c2..4ea153546 100644 --- a/frontend/src/main/app-state.ts +++ b/frontend/src/main/app-state.ts @@ -7,6 +7,17 @@ import path from "node:path"; * The Go reader is backend/internal/cli/start.go `appState`; the JSON keys * below MUST match its struct tags exactly (camelCase). */ + +export type MigrationStatus = "pending" | "completed" | "declined" | "failed"; + +export interface MigrationState { + status: MigrationStatus; + lastAttemptAt?: string; + completedAt?: string; + report?: { projectsImported: number; projectsSkipped: number }; + error?: string; +} + export interface AppStateMarker { schemaVersion: number; appPath: string; @@ -14,10 +25,11 @@ export interface AppStateMarker { installedAt: string; lastReconciledAt: string; installSource: string; + migration?: MigrationState; } /** Current marker format version (spec §5, schemaVersion field). */ -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; /** File name of the marker under the ~/.ao state dir. */ export const APP_STATE_FILE_NAME = "app-state.json"; @@ -58,6 +70,20 @@ async function readExisting(file: string): Promise { } } +/** + * Atomic write: temp file in the same dir, then rename. Mirrors the daemon's + * proven atomic write (backend/internal/runfile/runfile.go Write) so a + * concurrent `ao start` reader never observes a partial file. + */ +async function atomicWriteMarker(stateDir: string, marker: AppStateMarker): Promise { + await mkdir(stateDir, { recursive: true, mode: 0o750 }); + const file = path.join(stateDir, APP_STATE_FILE_NAME); + const data = `${JSON.stringify(marker, null, 2)}\n`; + const tmp = path.join(stateDir, `.app-state-${process.pid}-${Date.now()}.json`); + await writeFile(tmp, data, { mode: 0o600 }); + await rename(tmp, file); +} + /** * Write ~/.ao/app-state.json. The app is the SOLE writer (invariant 3) and * writes on every launch. Mirrors the daemon's proven atomic write @@ -68,11 +94,11 @@ async function readExisting(file: string): Promise { * On first creation, installedAt and installSource are captured and then * preserved across all later launches; appPath, version, and lastReconciledAt * are refreshed every launch (spec §5 field table). + * + * An existing `migration` block is preserved unchanged so a launch write does + * not erase a prior decision recorded by updateMigration. */ export async function writeAppStateMarker(opts: WriteAppStateOptions): Promise { - // 0o750: owner rwx, group r-x, world none — matches runfile.Write's dir mode. - await mkdir(opts.stateDir, { recursive: true, mode: 0o750 }); - const file = path.join(opts.stateDir, APP_STATE_FILE_NAME); const existing = await readExisting(file); const nowIso = opts.now().toISOString(); @@ -86,14 +112,42 @@ export async function writeAppStateMarker(opts: WriteAppStateOptions): Promise Date; +} + +// updateMigration sets ONLY the migration block, preserving every launch-written +// field already on disk. Used by the app's IPC setter. Atomic like the launch write. +export async function updateMigration(opts: UpdateMigrationOptions): Promise { + const file = path.join(opts.stateDir, APP_STATE_FILE_NAME); + const existing = await readExisting(file); + const nowIso = opts.now().toISOString(); + const marker: AppStateMarker = existing + ? { ...existing, migration: opts.migration } + : { + schemaVersion: SCHEMA_VERSION, + appPath: "", + version: "", + installedAt: nowIso, + lastReconciledAt: nowIso, + installSource: "unknown", + migration: opts.migration, + }; + await atomicWriteMarker(opts.stateDir, marker); +} + +// readMigrationState returns the marker's migration block, defaulting to pending +// when the file is absent or unparseable (self-healing, like the rest of the reader). +export async function readMigrationState(stateDir: string): Promise { + const existing = await readExisting(path.join(stateDir, APP_STATE_FILE_NAME)); + return existing?.migration ?? { status: "pending" }; } diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index c286a196a..c0000ff55 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron"; import type { BrowserNavState, BrowserRect } from "./main/browser-view-host"; import type { DaemonStatus } from "./shared/daemon-status"; import type { TelemetryBootstrap } from "./shared/telemetry"; +import type { MigrationState } from "./main/app-state"; export type BrowserBoundsInput = { viewId: string; @@ -68,6 +69,11 @@ const api = { }; }, }, + appState: { + getMigration: () => ipcRenderer.invoke("appState:getMigration") as Promise, + setMigration: (migration: MigrationState) => + ipcRenderer.invoke("appState:setMigration", migration) as Promise, + }, }; contextBridge.exposeInMainWorld("ao", api); diff --git a/frontend/src/renderer/components/MigrationPopup.test.tsx b/frontend/src/renderer/components/MigrationPopup.test.tsx new file mode 100644 index 000000000..add96fdc6 --- /dev/null +++ b/frontend/src/renderer/components/MigrationPopup.test.tsx @@ -0,0 +1,89 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MigrationPopup } from "./MigrationPopup"; + +const { getMock, postMock, getMigration, setMigration } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + getMigration: vi.fn(), + setMigration: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock, POST: postMock }, + apiErrorMessage: (e: unknown, fb = "Request failed") => + e instanceof Error ? e.message : ((e as { message?: string })?.message ?? fb), +})); +vi.mock("../lib/bridge", () => ({ aoBridge: { appState: { getMigration, setMigration } } })); + +function renderPopup() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return qc; +} + +beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + getMigration.mockReset(); + setMigration.mockReset(); + getMigration.mockResolvedValue({ status: "pending" }); + getMock.mockResolvedValue({ data: { available: true, legacyRoot: "/home/u/.agent-orchestrator" }, error: undefined }); + postMock.mockResolvedValue({ data: { report: { projectsImported: 2, projectsSkipped: 1 } }, error: undefined }); + setMigration.mockResolvedValue(undefined); +}); + +describe("MigrationPopup", () => { + it("shows when a legacy install is available and the marker is pending", async () => { + renderPopup(); + expect(await screen.findByText(/Import projects from your earlier AO/i)).toBeInTheDocument(); + expect(screen.getByText("/home/u/.agent-orchestrator")).toBeInTheDocument(); + }); + + it("renders nothing when the marker is declined", async () => { + getMigration.mockResolvedValue({ status: "declined" }); + renderPopup(); + await waitFor(() => expect(getMigration).toHaveBeenCalled()); + expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument(); + expect(getMock).not.toHaveBeenCalled(); + }); + + it("Proceed imports, marks completed, and retires", async () => { + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Proceed" })); + await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import")); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "completed" })); + await waitFor(() => expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument()); + }); + + it("Don't Migrate records declined", async () => { + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Don't Migrate" })); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "declined" })); + }); + + it("Skip dismisses without writing the marker", async () => { + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Skip" })); + expect(setMigration).not.toHaveBeenCalled(); + expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument(); + }); + + it("a failed import shows the lossless reassurance and marks failed", async () => { + postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } }); + renderPopup(); + await screen.findByText(/Import projects from your earlier AO/i); + await userEvent.click(screen.getByRole("button", { name: "Proceed" })); + expect(await screen.findByText(/nothing is ever deleted/i)).toBeInTheDocument(); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", error: "disk full" })); + }); +}); diff --git a/frontend/src/renderer/components/MigrationPopup.tsx b/frontend/src/renderer/components/MigrationPopup.tsx new file mode 100644 index 000000000..28c5a5ffc --- /dev/null +++ b/frontend/src/renderer/components/MigrationPopup.tsx @@ -0,0 +1,101 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button } from "./ui/button"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { aoBridge } from "../lib/bridge"; +import { migrationOfferQueryKey, useMigrationOffer } from "../hooks/useMigrationOffer"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; + +// MigrationPopup is the first-run legacy-AO import offer. It shows only when the +// app marker is non-terminal (pending/failed) AND the daemon reports legacy data +// available. Proceed runs the idempotent import through the daemon; Skip dismisses +// for this launch (re-prompts next launch); Don't Migrate declines permanently +// (re-runnable later once the Settings entry point lands, issue #2205). +export function MigrationPopup() { + const offer = useMigrationOffer(); + const queryClient = useQueryClient(); + const [skipped, setSkipped] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + const open = (offer.data?.show ?? false) && !skipped; + if (!open) return null; + + const legacyRoot = offer.data?.legacyRoot || "your earlier AO"; + const nowIso = () => new Date().toISOString(); + + const proceed = async () => { + setBusy(true); + setError(undefined); + const { data, error: apiErr } = await apiClient.POST("/api/v1/import"); + if (apiErr) { + const msg = apiErrorMessage(apiErr); + setError(msg); + await aoBridge.appState.setMigration({ status: "failed", lastAttemptAt: nowIso(), error: msg }); + setBusy(false); + return; + } + const report = data?.report; + await aoBridge.appState.setMigration({ + status: "completed", + lastAttemptAt: nowIso(), + completedAt: nowIso(), + report: report + ? { projectsImported: report.projectsImported, projectsSkipped: report.projectsSkipped } + : undefined, + }); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey }); + setSkipped(true); + setBusy(false); + }; + + const dontMigrate = async () => { + await aoBridge.appState.setMigration({ status: "declined", lastAttemptAt: nowIso() }); + await queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey }); + }; + + return ( + { + if (!next) setSkipped(true); + }} + > + + + + + Import projects from your earlier AO? + + + We found an existing install at {legacyRoot}. + Importing brings in your projects. Your old files are never modified, and you can do this later. + + {error && ( +
+ Migration failed: {error}. Your legacy projects are untouched (nothing is ever deleted). You can retry. +
+ )} +

You can run this again later.

+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/src/renderer/hooks/useMigrationOffer.ts b/frontend/src/renderer/hooks/useMigrationOffer.ts new file mode 100644 index 000000000..5bd77b726 --- /dev/null +++ b/frontend/src/renderer/hooks/useMigrationOffer.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "../lib/api-client"; +import { aoBridge } from "../lib/bridge"; +import type { MigrationState } from "../../main/app-state"; + +export const migrationOfferQueryKey = ["migration-offer"] as const; +const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1"; + +export interface MigrationOffer { + show: boolean; + legacyRoot: string; + migration: MigrationState; +} + +// fetchMigrationOffer combines the app marker (decision) with the daemon's +// availability (is there legacy data). A terminal marker (completed/declined) +// short-circuits before any daemon call. A 501/unreachable daemon resolves to +// "no offer", never an error. +async function fetchMigrationOffer(): Promise { + const migration = await aoBridge.appState.getMigration(); + if (migration.status === "completed" || migration.status === "declined") { + return { show: false, legacyRoot: "", migration }; + } + const { data, error } = await apiClient.GET("/api/v1/import"); + const legacyRoot = data?.legacyRoot ?? ""; + if (error || !data?.available) return { show: false, legacyRoot, migration }; + return { show: true, legacyRoot, migration }; +} + +export function useMigrationOffer() { + return useQuery({ + queryKey: migrationOfferQueryKey, + queryFn: fetchMigrationOffer, + enabled: !usePreviewData, + retry: 1, + throwOnError: false, + }); +} diff --git a/frontend/src/renderer/lib/bridge.ts b/frontend/src/renderer/lib/bridge.ts index 840e09899..0d16ee0c1 100644 --- a/frontend/src/renderer/lib/bridge.ts +++ b/frontend/src/renderer/lib/bridge.ts @@ -92,4 +92,8 @@ export const aoBridge: AoBridge = show: async () => undefined, onClick: () => () => undefined, }, + appState: { + getMigration: async () => ({ status: "pending" }), + setMigration: async () => undefined, + }, } satisfies AoBridge); diff --git a/frontend/src/renderer/routes/_shell.index.tsx b/frontend/src/renderer/routes/_shell.index.tsx index 6bfeef7fc..409a53636 100644 --- a/frontend/src/renderer/routes/_shell.index.tsx +++ b/frontend/src/renderer/routes/_shell.index.tsx @@ -1,6 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; +import { MigrationPopup } from "../components/MigrationPopup"; import { SessionsBoard } from "../components/SessionsBoard"; export const Route = createFileRoute("/_shell/")({ - component: () => , + component: () => ( + <> + + + + ), }); diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 795db7b4c..116d2cbb7 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -137,5 +137,9 @@ if (typeof window !== "undefined") { show: async () => undefined, onClick: () => () => undefined, }, + appState: { + getMigration: async () => ({ status: "pending" }), + setMigration: async () => undefined, + }, }; } // end if (typeof window !== "undefined") diff --git a/package-lock.json b/package-lock.json index 458892d11..9545254a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,382 +1,382 @@ { - "name": "agent-orchestrator", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agent-orchestrator", - "devDependencies": { - "openapi-typescript": "7.4.4" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@redocly/config": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", - "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core": { - "version": "1.34.15", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", - "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/ajv": "8.11.2", - "@redocly/config": "0.22.0", - "colorette": "1.4.0", - "https-proxy-agent": "7.0.6", - "js-levenshtein": "1.1.6", - "js-yaml": "4.1.1", - "minimatch": "5.1.9", - "pluralize": "8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/index-to-position": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", - "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-levenshtein": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", - "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/openapi-typescript": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.4.4.tgz", - "integrity": "sha512-7j3nktnRzlQdlHnHsrcr6Gqz8f80/RhfA2I8s1clPI+jkY0hLNmnYVKBfuUEli5EEgK1B6M+ibdS5REasPlsUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/openapi-core": "^1.25.9", - "ansi-colors": "^4.1.3", - "change-case": "^5.4.4", - "parse-json": "^8.1.0", - "supports-color": "^9.4.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "openapi-typescript": "bin/cli.js" - }, - "peerDependencies": { - "typescript": "^5.x" - } - }, - "node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-color": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", - "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uri-js-replace": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", - "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/yaml-ast-parser": { - "version": "0.0.43", - "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", - "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - } - } + "name": "agent-orchestrator", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-orchestrator", + "devDependencies": { + "openapi-typescript": "7.4.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/openapi-typescript": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.4.4.tgz", + "integrity": "sha512-7j3nktnRzlQdlHnHsrcr6Gqz8f80/RhfA2I8s1clPI+jkY0hLNmnYVKBfuUEli5EEgK1B6M+ibdS5REasPlsUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.25.9", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.1.0", + "supports-color": "^9.4.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", + "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } }