diff --git a/backend/internal/cli/import.go b/backend/internal/cli/import.go index b809f899..b046f446 100644 --- a/backend/internal/cli/import.go +++ b/backend/internal/cli/import.go @@ -27,12 +27,12 @@ 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 +71,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 +82,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 @@ -115,8 +114,6 @@ func writeImportSummary(w io.Writer, rep legacyimport.Report) error { 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) 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 f8f0d1d3..f05f500d 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -10,9 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) const defaultStartTimeout = 10 * time.Second @@ -77,11 +75,10 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da } } - // First-boot opt-in: before launching the daemon (so the import runs with the - // store unlocked and the daemon as sole writer afterwards), offer to import a - // legacy AO install. Declining or any import failure is non-fatal — the - // daemon still starts and the user can run `ao import` later. - c.maybeFirstBootImport(ctx, cfg) + // `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. The + // CLI never prompts here; `ao import` remains for explicit offline imports. exe, err := c.deps.Executable() if err != nil { @@ -118,49 +115,6 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da return ready, nil } -// maybeFirstBootImport offers to import a legacy AO install the first time the -// daemon is started against an empty rewrite database. It is best-effort: every -// failure path degrades to "start the daemon fresh" so a broken or absent legacy -// store can never block startup. A non-interactive boot (Electron/headless) -// never auto-imports; it prints a one-line hint to run `ao import` explicitly. -func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Config) { - root := legacyimport.DefaultLegacyRootDir() - if !legacyimport.HasLegacyData(root) { - return - } - - store, err := sqlite.Open(cfg.DataDir) - if err != nil { - return // the daemon will surface a real store error on its own open - } - defer func() { _ = store.Close() }() - - projects, err := store.ListProjects(ctx) - if err != nil || len(projects) > 0 { - // Already imported (or populated) — don't offer again. - return - } - - out := c.deps.Out - if !stdinIsInteractive(c.deps.In) { - _, _ = fmt.Fprintf(out, "Found existing AO projects at %s. Run `ao import` to bring them in.\n", root) - return - } - - ok, err := confirm(c.deps.In, out, "Found existing AO projects and sessions. Import them now?", true) - if err != nil || !ok { - _, _ = fmt.Fprintln(out, "Continuing fresh. Run `ao import` later to bring in your existing data.") - return - } - - rep, err := legacyimport.Run(ctx, store, legacyimport.Options{Root: root, DataDir: cfg.DataDir}) - if err != nil { - _, _ = fmt.Fprintf(out, "Import failed: %v\nContinuing fresh; legacy data is untouched. Retry with `ao import`.\n", err) - return - } - _ = writeImportSummary(out, rep) -} - func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration) (daemonStatus, error) { if timeout <= 0 { timeout = defaultStartTimeout diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index e9aac689..a6290d39 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -19,6 +19,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "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" @@ -134,6 +135,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 9026376d..3ec73691 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 a279d460..36f3a801 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: importStatus + 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: Report 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: Import projects and the orchestrator from a legacy AO install + tags: + - import /api/v1/notifications: get: operationId: listNotifications @@ -1337,6 +1386,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: @@ -1907,3 +1990,5 @@ tags: name: notifications - description: Server-sent CDC event stream with durable replay name: events +- description: 'Legacy AO import: detection and trigger' + name: import diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 2aeca734..478def47 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 import: detection and trigger"), } for _, op := range operations() { @@ -162,6 +164,11 @@ var schemaNames = map[string]string{ "ControllersNotificationTarget": "NotificationTarget", "ControllersNotificationResponse": "NotificationResponse", "ControllersListNotificationsResponse": "ListNotificationsResponse", + // httpd/controllers (import wire envelopes) + "ControllersImportStatusResponse": "ImportStatusResponse", + "ControllersImportRunResponse": "ImportRunResponse", + // internal/legacyimport + "LegacyimportReport": "ImportReport", // httpd/controllers — PR wire envelopes "ControllersMergePRResponse": "MergePRResponse", "ControllersResolveCommentsRequest": "ResolveCommentsRequest", @@ -259,9 +266,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 legacy-import operations. The set must stay +// 1:1 with the routes ImportController.Register mounts (TestRouteSpecParity). +func importOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/import", id: "importStatus", tag: "import", + summary: "Report 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: "Import projects and the orchestrator from a legacy AO install", + 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 2da2fe91..86de6f7a 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -6,9 +6,23 @@ 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" ) +// 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"` +} + // HTTP response envelopes for the projects surface — the SINGLE definition of // each wire shape. The handlers encode these (envelope.WriteJSON), and // apispec.Build reflects these same types into openapi.yaml, so the served diff --git a/backend/internal/httpd/controllers/imports.go b/backend/internal/httpd/controllers/imports.go new file mode 100644 index 00000000..ce4ae86d --- /dev/null +++ b/backend/internal/httpd/controllers/imports.go @@ -0,0 +1,58 @@ +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 legacy-import contract. nil keeps the +// routes registered but answers OpenAPI-backed 501s. +type ImportService interface { + Status(ctx context.Context) (importsvc.Status, error) + Run(ctx context.Context) (legacyimport.Report, error) +} + +// ImportController owns the /import routes: the dashboard polls GET to learn +// whether a legacy AO install is available, and POSTs to run the import. +type ImportController struct { + Svc ImportService +} + +// Register mounts the import 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 00000000..51442030 --- /dev/null +++ b/backend/internal/httpd/controllers/imports_test.go @@ -0,0 +1,122 @@ +package controllers_test + +import ( + "context" + "encoding/json" + "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/legacyimport" + importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" +) + +type fakeImportService struct { + status importsvc.Status + statusErr error + report legacyimport.Report + runErr error + runs int +} + +func (f *fakeImportService) Status(context.Context) (importsvc.Status, error) { + return f.status, f.statusErr +} + +func (f *fakeImportService) Run(context.Context) (legacyimport.Report, error) { + f.runs++ + return f.report, f.runErr +} + +func newImportTestServer(t *testing.T, svc *fakeImportService) *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{status: importsvc.Status{Available: true, LegacyRoot: "/home/u/.agent-orchestrator"}} + srv := newImportTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", status, body) + } + var resp struct { + Available bool `json:"available"` + LegacyRoot string `json:"legacyRoot"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !resp.Available || resp.LegacyRoot != "/home/u/.agent-orchestrator" { + t.Fatalf("resp = %+v", resp) + } +} + +func TestImportAPI_StatusError(t *testing.T) { + svc := &fakeImportService{statusErr: errors.New("boom")} + srv := newImportTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "") + if status != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500; body=%s", status, body) + } +} + +func TestImportAPI_Run(t *testing.T) { + svc := &fakeImportService{report: legacyimport.Report{ProjectsImported: 2, ProjectsSkipped: 1}} + srv := newImportTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "POST", "/api/v1/import", "") + if status != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", status, body) + } + if svc.runs != 1 { + t.Fatalf("runs = %d, want 1", svc.runs) + } + var resp struct { + Report struct { + ProjectsImported int `json:"projectsImported"` + ProjectsSkipped int `json:"projectsSkipped"` + } `json:"report"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Report.ProjectsImported != 2 || resp.Report.ProjectsSkipped != 1 { + 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("status = %d, want 500", status) + } +} + +func TestImportAPI_NotImplementedWhenNilService(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) + + _, status, _ := doRequest(t, srv, "GET", "/api/v1/import", "") + if status != http.StatusNotImplemented { + t.Fatalf("GET status = %d, want 501", status) + } + _, status, _ = doRequest(t, srv, "POST", "/api/v1/import", "") + if status != http.StatusNotImplemented { + t.Fatalf("POST status = %d, want 501", status) + } +} diff --git a/backend/internal/legacyimport/claude.go b/backend/internal/legacyimport/claude.go deleted file mode 100644 index 0c78a31c..00000000 --- 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 49ff8044..00000000 --- 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 9633adcd..7de39b4d 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,6 @@ type legacyConfig struct { type legacyProjectConfig struct { Path string `yaml:"path"` Name string `yaml:"name"` - Repo string `yaml:"repo"` DefaultBranch string `yaml:"defaultBranch"` SessionPrefix string `yaml:"sessionPrefix"` Env map[string]string `yaml:"env"` @@ -32,6 +32,13 @@ type legacyProjectConfig struct { Worker *legacyRole `yaml:"worker"` Orchestrator *legacyRole `yaml:"orchestrator"` + // repo is a structured block ({owner, name, platform, originUrl}) in the + // real legacy config, never the bare string it was first typed as. That + // mismatch made yaml.v3 raise a TypeError that dropped the whole registry. + // It is captured as a raw node (never consumed): the rewrite re-resolves the + // git origin from the repo path, so nothing here is imported. + Repo *yaml.Node `yaml:"repo"` + // Captured only to surface as dropped in the report (no rewrite home). Tracker *yaml.Node `yaml:"tracker"` SCM *yaml.Node `yaml:"scm"` @@ -65,7 +72,16 @@ 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) + // A *yaml.TypeError means individual fields didn't match their Go types + // (a legacy schema field the rewrite doesn't model, or one that drifted + // shape). yaml.v3 still decodes every field it could before returning it, + // so keep the partial result rather than dropping the whole registry: + // the importer only reads the fields it maps, and unmappable ones are + // surfaced or ignored downstream. Only a real read/syntax error is fatal. + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + } } return cfg, nil } diff --git a/backend/internal/legacyimport/config_test.go b/backend/internal/legacyimport/config_test.go new file mode 100644 index 00000000..8e5161c0 --- /dev/null +++ b/backend/internal/legacyimport/config_test.go @@ -0,0 +1,134 @@ +package legacyimport + +import ( + "path/filepath" + "testing" +) + +// realisticLegacyConfig mirrors the shape `ao project add` writes today: a +// structured `repo:` map (owner/name/platform/originUrl), plus the extra +// top-level keys the rewrite doesn't model. The bare-string `repo` field this +// package first declared made yaml.v3 raise a TypeError on this exact config, +// which dropped the whole registry and hid the dashboard import offer. +const realisticLegacyConfig = `port: 3000 +readyThresholdMs: 300000 +updateChannel: nightly +defaults: + runtime: tmux + agent: claude-code +projects: + harshitsinghbhandari-github-io: + projectId: harshitsinghbhandari-github-io + path: /Users/h/harshitsinghbhandari.github.io + repo: + owner: harshitsinghbhandari + name: harshitsinghbhandari.github.io + platform: github + originUrl: https://github.com/harshitsinghbhandari/harshitsinghbhandari.github.io + defaultBranch: main + source: ao-project-add + registeredAt: 1776846948 + displayName: Harshitsinghbhandari.Github.Io + sessionPrefix: har + storageKey: 72c8a68fac42 + agent-orchestrator_1a434010b7: + projectId: agent-orchestrator_1a434010b7 + path: /Users/h/Downloads/agent-orchestrator + repo: + owner: harshitsinghbhandari + name: agent-orchestrator + platform: github + originUrl: https://github.com/harshitsinghbhandari/agent-orchestrator + defaultBranch: develop + source: ao-project-add + displayName: Agent Orchestrator + sessionPrefix: ao +` + +func TestLoadLegacyConfig_RepoAsMap(t *testing.T) { + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + mustMkdir(t, root) + mustWrite(t, filepath.Join(root, "config.yaml"), realisticLegacyConfig) + + cfg, err := loadLegacyConfig(root) + if err != nil { + t.Fatalf("loadLegacyConfig: %v", err) + } + if len(cfg.Projects) != 2 { + t.Fatalf("parsed %d projects, want 2 (a structured repo map must not drop the registry)", len(cfg.Projects)) + } + + ghio, ok := cfg.Projects["harshitsinghbhandari-github-io"] + if !ok { + t.Fatal("missing project harshitsinghbhandari-github-io") + } + if ghio.SessionPrefix != "har" || ghio.DefaultBranch != "main" { + t.Fatalf("ghio = %+v, want sessionPrefix=har defaultBranch=main", ghio) + } + if ghio.Repo == nil { + t.Fatal("repo node should be captured (not consumed, just parsed without error)") + } + + ao, ok := cfg.Projects["agent-orchestrator_1a434010b7"] + if !ok { + t.Fatal("missing project agent-orchestrator_1a434010b7") + } + if ao.Path != "/Users/h/Downloads/agent-orchestrator" || ao.DefaultBranch != "develop" { + t.Fatalf("ao = %+v, want path + defaultBranch=develop", ao) + } + + // HasLegacyData drives the dashboard offer: it must see these projects. + if !HasLegacyData(root) { + t.Fatal("HasLegacyData = false for a real config with a repo map, want true") + } +} + +// TestLoadLegacyConfig_TolerateTypeError covers the defense-in-depth path: a +// field that drifts to an unexpected YAML type (here `path` as a map) raises a +// *yaml.TypeError, but the importer must keep every field/project yaml.v3 still +// decoded rather than discarding the whole registry. +func TestLoadLegacyConfig_TolerateTypeError(t *testing.T) { + const cfgYAML = `projects: + good: + path: /repos/good + defaultBranch: main + sessionPrefix: gd + bad: + path: + nested: oops + sessionPrefix: bd +` + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + mustMkdir(t, root) + mustWrite(t, filepath.Join(root, "config.yaml"), cfgYAML) + + cfg, err := loadLegacyConfig(root) + if err != nil { + t.Fatalf("loadLegacyConfig should tolerate a TypeError, got: %v", err) + } + if len(cfg.Projects) != 2 { + t.Fatalf("parsed %d projects, want 2 (partial decode must survive)", len(cfg.Projects)) + } + if good := cfg.Projects["good"]; good.Path != "/repos/good" || good.SessionPrefix != "gd" { + t.Fatalf("good project lost fields: %+v", good) + } + // The mistyped field is empty, but its sibling keys still decoded. + if bad := cfg.Projects["bad"]; bad.SessionPrefix != "bd" { + t.Fatalf("bad project should keep its well-typed siblings: %+v", bad) + } + if !HasLegacyData(root) { + t.Fatal("HasLegacyData = false despite a partial parse, want true") + } +} + +// TestLoadLegacyConfig_SyntaxErrorIsFatal guards the boundary: a genuinely +// malformed document (not a type mismatch) must still hard-fail. +func TestLoadLegacyConfig_SyntaxErrorIsFatal(t *testing.T) { + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + mustMkdir(t, root) + mustWrite(t, filepath.Join(root, "config.yaml"), "projects: : : not yaml\n - [unbalanced\n") + + if _, err := loadLegacyConfig(root); err == nil { + t.Fatal("expected a syntax error to be fatal, got nil") + } +} diff --git a/backend/internal/legacyimport/importer.go b/backend/internal/legacyimport/importer.go index cc3712f3..7955fd05 100644 --- a/backend/internal/legacyimport/importer.go +++ b/backend/internal/legacyimport/importer.go @@ -15,26 +15,19 @@ 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 project but writes nothing. DryRun bool - // ClaudeProjectsDir overrides ~/.claude/projects (tests). - ClaudeProjectsDir string // 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. @@ -43,14 +36,10 @@ type Options struct { // 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 its projects into store. It never +// modifies legacy files. It is idempotent: existing rows are skipped. A +// per-project parse 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) @@ -242,3 +167,12 @@ func defaultRepoOriginURL(path string) string { } return strings.TrimSpace(string(out)) } + +// 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 + `"` +} diff --git a/backend/internal/legacyimport/importer_test.go b/backend/internal/legacyimport/importer_test.go index f0ce33af..d174307f 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,28 +27,13 @@ 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 (alpha carries a +// non-default branch; beta is bare). 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") - mustMkdir(t, filepath.Join(root, "projects", "alpha", "sessions")) - mustMkdir(t, filepath.Join(root, "projects", "beta", "sessions")) - + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + mustMkdir(t, filepath.Join(root, "projects")) mustWrite(t, filepath.Join(root, "config.yaml"), `projects: alpha: path: /repos/alpha @@ -58,72 +42,28 @@ 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(context.Background(), 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 +71,33 @@ 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(context.Background(), store, runOpts(root)); err != nil { t.Fatalf("first run: %v", err) } - rep, err := Run(ctx, store, runOpts(root, claudeDir)) + rep, err := Run(context.Background(), 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 +114,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 ec55d5c0..00000000 --- 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 8e5b6305..00000000 --- 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 f1150ead..7c9b6ba0 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 48dee926..e4c1402c 100644 --- a/backend/internal/legacyimport/project_test.go +++ b/backend/internal/legacyimport/project_test.go @@ -4,9 +4,17 @@ import ( "testing" "time" + yaml "gopkg.in/yaml.v3" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) +// nonNilNode returns a populated *yaml.Node, standing in for a captured legacy +// config block (e.g. tracker) the rewrite has no home for. +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 00000000..d15f10ac --- /dev/null +++ b/backend/internal/service/importer/importer.go @@ -0,0 +1,91 @@ +// Package importer is the controller-facing service for the legacy-AO import. +// It wraps the internal/legacyimport engine (merged in #314) with the two +// operations the dashboard needs: a detection probe ("is a legacy install +// available to import?") and a trigger that runs the import through the live +// daemon's store. 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, so the +// daemon stays the sole writer. +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, matching the first-boot opt-in condition: a populated rewrite +// is assumed to have already been imported (or started fresh on purpose), so +// the offer is not surfaced again. +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 + // (~/.agent-orchestrator). + 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}) +} diff --git a/backend/internal/service/importer/importer_test.go b/backend/internal/service/importer/importer_test.go new file mode 100644 index 00000000..553cfe8c --- /dev/null +++ b/backend/internal/service/importer/importer_test.go @@ -0,0 +1,135 @@ +package importer + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// fakeStore satisfies importer.Store with the engine's idempotency semantics +// plus the project listing the service's detection probe reads. +type fakeStore struct { + projects map[string]domain.ProjectRecord + listErr error +} + +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 (f *fakeStore) ListProjects(_ context.Context) ([]domain.ProjectRecord, error) { + if f.listErr != nil { + return nil, f.listErr + } + out := make([]domain.ProjectRecord, 0, len(f.projects)) + for _, p := range f.projects { + out = append(out, p) + } + return out, nil +} + +// writeLegacyRoot writes the minimal legacy store the detection probe needs: a +// config.yaml with one project. +func writeLegacyRoot(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + if err := os.MkdirAll(filepath.Join(root, "projects", "alpha", "sessions"), 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 { + t.Fatalf("status: %v", err) + } + if st.Available { + t.Fatal("Available = true with no legacy data, want false") + } +} + +func TestStatus_LegacyPresentEmptyDB(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + st, err := svc.Status(context.Background()) + if err != nil { + t.Fatalf("status: %v", err) + } + if !st.Available { + t.Fatal("Available = false with legacy data + empty DB, want true") + } + if st.LegacyRoot != root { + t.Fatalf("LegacyRoot = %q, want %q", st.LegacyRoot, root) + } +} + +func TestStatus_AlreadyPopulated(t *testing.T) { + root := writeLegacyRoot(t) + store := newFakeStore() + store.projects["existing"] = domain.ProjectRecord{ID: "existing"} + svc := New(Deps{Store: store, Root: root}) + st, err := svc.Status(context.Background()) + if err != nil { + t.Fatalf("status: %v", err) + } + if st.Available { + t.Fatal("Available = true with a populated DB, want false") + } +} + +func TestStatus_ListError(t *testing.T) { + root := writeLegacyRoot(t) + store := newFakeStore() + store.listErr = errors.New("boom") + svc := New(Deps{Store: store, Root: root}) + if _, err := svc.Status(context.Background()); err == nil { + t.Fatal("expected ListProjects error to propagate") + } +} + +func TestRun_ImportsThenStatusFlipsUnavailable(t *testing.T) { + root := writeLegacyRoot(t) + store := newFakeStore() + svc := New(Deps{Store: store, Root: root}) + + rep, err := svc.Run(context.Background()) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.ProjectsImported != 1 { + t.Fatalf("projectsImported = %d, want 1", rep.ProjectsImported) + } + // After a successful import the DB is populated, so the offer retires. + st, err := svc.Status(context.Background()) + if err != nil { + t.Fatalf("status: %v", err) + } + if st.Available { + t.Fatal("Available = true after import, want false") + } +} + +func TestNew_DefaultsRoot(t *testing.T) { + svc := New(Deps{Store: newFakeStore()}) + if svc.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 a6ef74b6..00000000 --- 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 87c19bd8..00000000 --- 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/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 0bb8bd4a..cdfb70af 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; + }; + /** Report whether a legacy AO install is available to import */ + get: operations["importStatus"]; + put?: never; + /** Import projects and the orchestrator from a legacy AO install */ + post: operations["runImport"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/notifications": { parameters: { query?: never; @@ -476,6 +494,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; @@ -745,6 +776,82 @@ export interface operations { }; }; }; + importStatus: { + 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 112cb75a..ca489e0b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,7 +7,12 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { resolveDaemonLaunch } from "./shared/daemon-launch"; -import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery"; +import { + createListenPortScanner, + defaultRunFilePath, + parseRunFile, + shouldAdoptDiscoveredPort, +} from "./shared/daemon-discovery"; import type { DaemonStatus } from "./shared/daemon-status"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; @@ -139,6 +144,10 @@ function createWindow(): void { // a best-effort fallback. const PORT_DISCOVERY_TIMEOUT_MS = 15_000; const RUN_FILE_POLL_MS = 300; +// Cadence for discovering a daemon this app did NOT spawn (see +// startExternalDaemonDiscovery). Slower than the spawn-path poll: this is a +// background watch, not a startup handshake. +const EXTERNAL_DISCOVERY_POLL_MS = 1_000; // Accept run-files stamped slightly before our spawn timestamp: the daemon's // clock reading and ours race within normal scheduling jitter. const RUN_FILE_FRESHNESS_SKEW_MS = 2_000; @@ -148,6 +157,41 @@ function runFilePath(): string | null { return defaultRunFilePath(process.platform, process.env, os.homedir()); } +let externalDiscoveryTimer: ReturnType | undefined; + +// Discover a daemon this app did NOT spawn (e.g. a developer who ran `ao start` +// in a terminal) from its running.json handshake. Without this the renderer +// would stay pinned to its compiled default base URL and never learn the real +// bound port; worse, that default resolves localhost to IPv6 and can land on an +// unrelated server. running.json carries the real port and we target +// 127.0.0.1, sidestepping the collision. +// +// It stays passive whenever we own the daemon process: the spawn path already +// runs freshness-checked discovery there, so this never races it or trusts a +// stale handshake mid-spawn. +function discoverExternalDaemonOnce(): void { + if (daemonProcess !== null) return; + const handshakePath = runFilePath(); + if (!handshakePath) return; + readFile(handshakePath, "utf8") + .then((contents) => { + if (daemonProcess !== null) return; // a spawn started while we read + const info = parseRunFile(contents); + if (info && shouldAdoptDiscoveredPort(daemonStatus, info.port)) { + setDaemonStatus({ state: "ready", port: info.port }); + } + }) + .catch(() => undefined); // absent until a daemon binds; keep polling +} + +// Always-on discovery, started once on app ready and independent of whether we +// spawned the daemon. Reads running.json immediately, then polls. +function startExternalDaemonDiscovery(): void { + if (externalDiscoveryTimer) return; + discoverExternalDaemonOnce(); + externalDiscoveryTimer = setInterval(discoverExternalDaemonOnce, EXTERNAL_DISCOVERY_POLL_MS); +} + function daemonEnv(): NodeJS.ProcessEnv { return { ...process.env, @@ -320,7 +364,9 @@ ipcMain.handle("daemon:getStatus", () => daemonStatus); ipcMain.handle("daemon:start", () => startDaemon()); ipcMain.handle("daemon:stop", () => stopDaemon()); ipcMain.handle("app:getVersion", () => app.getVersion()); -ipcMain.handle("telemetry:getBootstrap", () => buildTelemetryBootstrap(process.env, app.getVersion(), process.platform)); +ipcMain.handle("telemetry:getBootstrap", () => + buildTelemetryBootstrap(process.env, app.getVersion(), process.platform), +); ipcMain.handle("app:chooseDirectory", async () => { const options: OpenDialogOptions = { properties: ["openDirectory"], @@ -345,6 +391,7 @@ app.whenReady().then(() => { registerRendererProtocol(); createWindow(); startDaemon(); + startExternalDaemonDiscovery(); initAutoUpdates(); app.on("activate", () => { @@ -355,6 +402,10 @@ app.whenReady().then(() => { }); app.on("before-quit", () => { + if (externalDiscoveryTimer) { + clearInterval(externalDiscoveryTimer); + externalDiscoveryTimer = undefined; + } if (daemonProcess) { killDaemon(daemonProcess); } diff --git a/frontend/src/renderer/components/ImportOffer.test.tsx b/frontend/src/renderer/components/ImportOffer.test.tsx new file mode 100644 index 00000000..c57c4e62 --- /dev/null +++ b/frontend/src/renderer/components/ImportOffer.test.tsx @@ -0,0 +1,93 @@ +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 { ImportOffer } from "./ImportOffer"; + +const { getMock, postMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { + GET: getMock, + POST: postMock, + }, + apiErrorMessage: (error: unknown, fallback = "Request failed") => { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error) { + return String((error as { message: unknown }).message); + } + return fallback; + }, +})); + +function renderOffer() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + render( + + + , + ); + return queryClient; +} + +beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + getMock.mockResolvedValue({ data: { available: true, legacyRoot: "/home/u/.agent-orchestrator" }, error: undefined }); + postMock.mockResolvedValue({ data: { report: { projectsImported: 2 } }, error: undefined }); +}); + +describe("ImportOffer", () => { + it("shows the offer when the daemon reports an importable install", async () => { + renderOffer(); + expect(await screen.findByText(/Import projects from your earlier AO/i)).toBeInTheDocument(); + expect(screen.getByText("/home/u/.agent-orchestrator")).toBeInTheDocument(); + }); + + it("renders nothing when no install is available", async () => { + getMock.mockResolvedValue({ data: { available: false, legacyRoot: "" }, error: undefined }); + renderOffer(); + await waitFor(() => expect(getMock).toHaveBeenCalled()); + expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument(); + }); + + it("runs the import on accept", async () => { + renderOffer(); + await screen.findByText(/Import projects from your earlier AO/i); + + await userEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/import"); + // On success the banner retires. + await waitFor(() => expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument()); + }); + + it("dismisses without importing on decline", async () => { + renderOffer(); + await screen.findByText(/Import projects from your earlier AO/i); + + await userEvent.click(screen.getByRole("button", { name: "Not now" })); + + expect(screen.queryByText(/Import projects from your earlier AO/i)).not.toBeInTheDocument(); + expect(postMock).not.toHaveBeenCalled(); + }); + + it("surfaces the daemon error when the import fails", async () => { + postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } }); + renderOffer(); + await screen.findByText(/Import projects from your earlier AO/i); + + await userEvent.click(screen.getByRole("button", { name: "Import" })); + + expect(await screen.findByText(/disk full/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/ImportOffer.tsx b/frontend/src/renderer/components/ImportOffer.tsx new file mode 100644 index 00000000..80d8a218 --- /dev/null +++ b/frontend/src/renderer/components/ImportOffer.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { useImportStatus, useRunImport } from "../hooks/useImportStatus"; + +// ImportOffer surfaces the first-run legacy-AO import opt-in on the dashboard. +// `ao start` is headless and never prompts; the daemon reports availability via +// GET /api/v1/import, and this banner is where the user accepts or declines. +// +// Accept runs the import through the live daemon (POST /api/v1/import); on +// success the workspace query is invalidated so the imported projects appear, +// and the offer retires (the DB is no longer empty). Decline dismisses for the +// session; the data is untouched and the user can run `ao import` or restart +// later. +export function ImportOffer() { + const status = useImportStatus(); + const runImport = useRunImport(); + const [dismissed, setDismissed] = useState(false); + + const available = status.data?.available ?? false; + if (!available || dismissed || runImport.isSuccess) return null; + + const legacyRoot = status.data?.legacyRoot ?? "your earlier AO"; + const error = runImport.error?.message; + + return ( +
+
+
+

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 instead. +

+ {error &&

Import failed: {error}

} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 6649e1d5..3ffe10c7 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -10,6 +10,7 @@ import { } from "../types/workspace"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; import { DashboardSubhead } from "./DashboardSubhead"; +import { ImportOffer } from "./ImportOffer"; import { cn } from "../lib/utils"; type SessionsBoardProps = { @@ -98,6 +99,11 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
+ {/* First-run legacy-AO import opt-in. Renders only when the daemon + reports an importable install (and only on the top-level board, not + a project-scoped view). */} + {!projectId && } +
{workspaceQuery.isError ? (

Could not load sessions.

diff --git a/frontend/src/renderer/components/TelemetryBoundary.tsx b/frontend/src/renderer/components/TelemetryBoundary.tsx index 3868a77d..9ed73c07 100644 --- a/frontend/src/renderer/components/TelemetryBoundary.tsx +++ b/frontend/src/renderer/components/TelemetryBoundary.tsx @@ -29,7 +29,9 @@ export class TelemetryBoundary extends React.Component {

The app hit an unexpected error.

-

Restart the app or check the daemon logs if this keeps happening.

+

+ Restart the app or check the daemon logs if this keeps happening. +

); diff --git a/frontend/src/renderer/hooks/useImportStatus.ts b/frontend/src/renderer/hooks/useImportStatus.ts new file mode 100644 index 00000000..ee9d91c1 --- /dev/null +++ b/frontend/src/renderer/hooks/useImportStatus.ts @@ -0,0 +1,54 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { workspaceQueryKey } from "./useWorkspaceQuery"; + +export const importStatusQueryKey = ["import-status"] as const; +const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1"; + +export type ImportStatus = { available: boolean; legacyRoot: string }; + +export type ImportReport = { + projectsImported: number; + projectsSkipped: number; + notes?: string[]; +}; + +async function fetchImportStatus(): Promise { + const { data, error } = await apiClient.GET("/api/v1/import"); + if (error) throw new Error(apiErrorMessage(error)); + return { available: data?.available ?? false, legacyRoot: data?.legacyRoot ?? "" }; +} + +// useImportStatus polls the daemon for the first-run import offer. The offer +// only appears on a fresh, un-imported database and retires the moment data +// lands, so a slow poll is plenty. A daemon that doesn't implement the endpoint +// (501) or is unreachable resolves to "no offer" rather than surfacing an +// error. This is an opt-in convenience, never a blocker. +export function useImportStatus() { + return useQuery({ + queryKey: importStatusQueryKey, + queryFn: fetchImportStatus, + enabled: !usePreviewData, + refetchInterval: 30_000, + retry: 1, + throwOnError: false, + }); +} + +// useRunImport triggers the legacy import through the live daemon and, on +// success, invalidates both the import status (so the offer retires) and the +// workspace query (so the imported projects appear). +export function useRunImport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const { data, error } = await apiClient.POST("/api/v1/import"); + if (error) throw new Error(apiErrorMessage(error)); + return (data?.report ?? {}) as ImportReport; + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: importStatusQueryKey }); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }, + }); +} diff --git a/frontend/src/renderer/lib/telemetry.test.ts b/frontend/src/renderer/lib/telemetry.test.ts index da7de90e..3b8d9f6f 100644 --- a/frontend/src/renderer/lib/telemetry.test.ts +++ b/frontend/src/renderer/lib/telemetry.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - routeSurface, - sanitizeRendererExceptionProperties, - sanitizeRendererProperties, -} from "./telemetry"; +import { routeSurface, sanitizeRendererExceptionProperties, sanitizeRendererProperties } from "./telemetry"; describe("telemetry sanitizers", () => { it("categorizes routes without exporting raw paths", () => { diff --git a/frontend/src/renderer/lib/telemetry.ts b/frontend/src/renderer/lib/telemetry.ts index e03e853e..cb66a741 100644 --- a/frontend/src/renderer/lib/telemetry.ts +++ b/frontend/src/renderer/lib/telemetry.ts @@ -70,7 +70,7 @@ export async function sanitizeRendererProperties( case "ao.renderer.orchestrator_open_requested": { const projectIDHash = await hashedTelemetryID(properties?.project_id); if (projectIDHash) safe.project_id_hash = projectIDHash; - break + break; } } return safe; @@ -157,10 +157,7 @@ export async function captureRendererEvent(event: string, properties?: Record, -): Promise { +export async function captureRendererException(error: unknown, properties?: Record): Promise { if (!(await initTelemetry())) return; const safeProperties = await sanitizeRendererExceptionProperties(error, properties); posthog.capture("ao.renderer.exception", safeProperties); diff --git a/frontend/src/shared/daemon-discovery.test.ts b/frontend/src/shared/daemon-discovery.test.ts index c0939bfc..ea0149f0 100644 --- a/frontend/src/shared/daemon-discovery.test.ts +++ b/frontend/src/shared/daemon-discovery.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { createListenPortScanner, defaultRunFilePath, parseDaemonListenPort, parseRunFile } from "./daemon-discovery"; +import { + createListenPortScanner, + defaultRunFilePath, + parseDaemonListenPort, + parseRunFile, + shouldAdoptDiscoveredPort, +} from "./daemon-discovery"; // Real shape emitted by slog's TextHandler in backend/internal/httpd/server.go. const LISTEN_LINE = 'time=2026-06-10T09:15:04.221-07:00 level=INFO msg="daemon listening" addr=127.0.0.1:3001 pid=4242'; @@ -90,6 +96,23 @@ describe("parseRunFile", () => { }); }); +describe("shouldAdoptDiscoveredPort", () => { + it("adopts a discovered port when not yet ready (the externally-started daemon case)", () => { + expect(shouldAdoptDiscoveredPort({ state: "stopped" }, 3000)).toBe(true); + expect(shouldAdoptDiscoveredPort({ state: "starting" }, 3000)).toBe(true); + expect(shouldAdoptDiscoveredPort({ state: "error", message: "x" }, 3000)).toBe(true); + }); + + it("adopts when ready on a different port (daemon rebound elsewhere)", () => { + expect(shouldAdoptDiscoveredPort({ state: "ready", port: 3001 }, 3000)).toBe(true); + expect(shouldAdoptDiscoveredPort({ state: "ready", port: undefined }, 3000)).toBe(true); + }); + + it("stays a no-op once already pointed at that exact port", () => { + expect(shouldAdoptDiscoveredPort({ state: "ready", port: 3000 }, 3000)).toBe(false); + }); +}); + describe("defaultRunFilePath", () => { it("matches Go's canonical AO home default on macOS", () => { expect(defaultRunFilePath("darwin", {}, "/Users/me")).toBe("/Users/me/.ao/running.json"); diff --git a/frontend/src/shared/daemon-discovery.ts b/frontend/src/shared/daemon-discovery.ts index 16f407cb..f16b8e61 100644 --- a/frontend/src/shared/daemon-discovery.ts +++ b/frontend/src/shared/daemon-discovery.ts @@ -9,12 +9,27 @@ // so tests can exercise them directly; the Electron main process owns the // streams, fs polling, and timers. +import type { DaemonStatus } from "./daemon-status"; + // Minimal join: "/" works for fs access on every platform Node supports, // including Windows paths that already contain backslashes (e.g. %APPDATA%). function joinPath(...segments: string[]): string { return segments.map((segment) => segment.replace(/[/\\]+$/, "")).join("/"); } +/** + * Decide whether to adopt a port discovered from running.json, given the + * supervisor's current status. Adopt unless we already report that exact port + * as ready — so an externally-started daemon (one this app did not spawn, e.g. + * a developer running `ao start` in a terminal) gets discovered, while a steady + * state stays a no-op and never churns the status. The caller separately gates + * this on "we did not spawn the daemon ourselves", so it never races the spawn + * path's own freshness-checked discovery. + */ +export function shouldAdoptDiscoveredPort(current: DaemonStatus, discoveredPort: number): boolean { + return current.state !== "ready" || current.port !== discoveredPort; +} + /** * Parse one daemon log line for the listen announcement. slog's TextHandler * emits `time=… level=INFO msg="daemon listening" addr=127.0.0.1:3001 pid=…`; diff --git a/frontend/src/shared/telemetry.test.ts b/frontend/src/shared/telemetry.test.ts index 8cb4323e..f3684dd5 100644 --- a/frontend/src/shared/telemetry.test.ts +++ b/frontend/src/shared/telemetry.test.ts @@ -7,7 +7,11 @@ import { buildTelemetryBootstrap, defaultDataDir, loadOrCreateTelemetryInstallId const tempDirs: string[] = []; afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true })))); + await Promise.all( + tempDirs + .splice(0) + .map((dir) => import("node:fs/promises").then(({ rm }) => rm(dir, { recursive: true, force: true }))), + ); }); test("defaultDataDir prefers AO_DATA_DIR", () => {