From 554e332b80c97f1bbaace7ee9e8663ee75daeafb Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 15:56:13 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(import):=20rewrite-side=20legacy=20?= =?UTF-8?q?=E2=86=92=20rewrite=20first-boot=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the legacy-side TS reader (AgentWrapper #2144/#2129) to Go and run the migration inside the rewrite as an opt-in import, per the FINAL v2 plan. Reads the legacy flat-file store (~/.agent-orchestrator) read-only and writes the rewrite's own SQLite DB via the native storage layer; legacy files are never touched, and a re-run skips existing rows, so a declined or failed import loses nothing. What's included: - internal/legacyimport: Go reader + field mappers (issue #247). Lifecycle double-decode (lifecycle key or statePayload+stateVersion:"2"), role/orchestrator detection, sessionPrefix fallback (first 12 chars of id), 8→4 activity-state map, per-harness resume-id selection, permission/harness remap, and the claude transcript slug + relocation to the rewrite's orchestrator worktree path ({DataDir}/worktrees/{id}/orchestrator/{prefix}-orchestrator). - store.ImportSession: verbatim session insert (explicit id/num, ON CONFLICT DO NOTHING) so the orchestrator lands at id "{prefix}-orchestrator", num 0. - `ao import`: explicit, idempotent import with --from/--dry-run/--yes/--json. Refuses while a live daemon owns the run-file (the daemon is sole writer; the import runs offline, matching the #2129 reference). - First-boot opt-in: `ao start` offers the import before launching the daemon when legacy data is present and the rewrite DB has no projects yet. Declining or any failure is non-fatal; a non-interactive boot prints a hint instead of auto-importing. Scope (gist §6): all projects + per-project settings, and the single non-terminated orchestrator session per project (claude-code/codex/opencode; aider skipped with a note). Workers are not imported (they respawn fresh). Resume-id mapping (#247 §2.2): agent_session_id carries claudeSessionUuid / codexThreadId / opencodeSessionId by harness. codexModel and restoreFallbackReason have no rewrite column, so they are dropped and surfaced as import notes — codex resumes from the thread id alone, the rest is forensic. Gate: `go build ./... && go test -race ./...` green (1423 tests). Co-Authored-By: Claude Opus 4.8 --- backend/internal/cli/import.go | 174 +++++++++ backend/internal/cli/import_test.go | 70 ++++ backend/internal/cli/root.go | 1 + backend/internal/cli/start.go | 51 +++ backend/internal/legacyimport/claude.go | 101 ++++++ backend/internal/legacyimport/claude_test.go | 70 ++++ backend/internal/legacyimport/config.go | 126 +++++++ backend/internal/legacyimport/importer.go | 243 +++++++++++++ .../internal/legacyimport/importer_test.go | 202 +++++++++++ backend/internal/legacyimport/orchestrator.go | 340 ++++++++++++++++++ .../legacyimport/orchestrator_test.go | 156 ++++++++ backend/internal/legacyimport/paths.go | 95 +++++ backend/internal/legacyimport/project.go | 212 +++++++++++ backend/internal/legacyimport/project_test.go | 126 +++++++ .../sqlite/store/session_import_store.go | 60 ++++ .../sqlite/store/session_import_store_test.go | 58 +++ 16 files changed, 2085 insertions(+) create mode 100644 backend/internal/cli/import.go create mode 100644 backend/internal/cli/import_test.go create mode 100644 backend/internal/legacyimport/claude.go create mode 100644 backend/internal/legacyimport/claude_test.go create mode 100644 backend/internal/legacyimport/config.go create mode 100644 backend/internal/legacyimport/importer.go create mode 100644 backend/internal/legacyimport/importer_test.go create mode 100644 backend/internal/legacyimport/orchestrator.go create mode 100644 backend/internal/legacyimport/orchestrator_test.go create mode 100644 backend/internal/legacyimport/paths.go create mode 100644 backend/internal/legacyimport/project.go create mode 100644 backend/internal/legacyimport/project_test.go create mode 100644 backend/internal/storage/sqlite/store/session_import_store.go create mode 100644 backend/internal/storage/sqlite/store/session_import_store_test.go diff --git a/backend/internal/cli/import.go b/backend/internal/cli/import.go new file mode 100644 index 00000000..b809f899 --- /dev/null +++ b/backend/internal/cli/import.go @@ -0,0 +1,174 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + + "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" +) + +type importOptions struct { + from string + dryRun bool + yes bool + json bool +} + +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", + 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" + + "The daemon must be stopped: it is the sole writer of the database.", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return ctx.runImport(cmd, opts) + }, + } + cmd.Flags().StringVar(&opts.from, "from", "", "Legacy AO root to read (default ~/.agent-orchestrator)") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Parse and report the planned import without writing") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt (for non-interactive use)") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output the import report as JSON") + return cmd +} + +func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error { + cfg, err := config.Load() + if err != nil { + return err + } + + // The daemon is the sole writer; refuse to open the store underneath a live + // one. A stale run-file (dead PID) is treated as safe. + if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil { + return fmt.Errorf("inspect run-file: %w", err) + } else if live != nil { + return usageError{fmt.Errorf("the AO daemon is running (pid %d); stop it first with `ao stop` before importing", live.PID)} + } + + root := opts.from + if root == "" { + root = legacyimport.DefaultLegacyRootDir() + } + if !legacyimport.HasLegacyData(root) { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "No legacy AO projects found at %s. Nothing to import.\n", root) + return err + } + + if !opts.dryRun && !opts.yes { + ok, err := confirm(c.deps.In, cmd.OutOrStdout(), + fmt.Sprintf("Import projects and orchestrator sessions from %s?", root), true) + if err != nil { + return err + } + if !ok { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "Import cancelled.") + return err + } + } + + rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{ + Root: root, + DataDir: cfg.DataDir, + DryRun: opts.dryRun, + }) + if err != nil { + return err + } + + if opts.json { + return writeJSON(cmd.OutOrStdout(), rep) + } + return writeImportSummary(cmd.OutOrStdout(), rep) +} + +// executeImport opens the rewrite store, runs the import, and closes the store. +// It is the one CLI path that opens the database directly: the import is a +// one-time bootstrap that must run with the daemon stopped (guarded by the +// caller), so it cannot go through the daemon's loopback API. +func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, opts legacyimport.Options) (legacyimport.Report, error) { + store, err := sqlite.Open(cfg.DataDir) + if err != nil { + return legacyimport.Report{}, fmt.Errorf("open store: %w", err) + } + defer func() { _ = store.Close() }() + return legacyimport.Run(ctx, store, opts) +} + +func writeImportSummary(w io.Writer, rep legacyimport.Report) error { + var b strings.Builder + if rep.DryRun { + 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 { + fmt.Fprintf(&b, " - %s\n", n) + } + } + _, err := io.WriteString(w, b.String()) + return err +} + +// confirm prompts for a yes/no answer. When stdin is not an interactive +// terminal it returns the default without prompting, so headless invocations +// behave deterministically. +func confirm(in io.Reader, out io.Writer, prompt string, defaultYes bool) (bool, error) { + suffix := " [Y/n] " + if !defaultYes { + suffix = " [y/N] " + } + if !stdinIsInteractive(in) { + return defaultYes, nil + } + if _, err := io.WriteString(out, prompt+suffix); err != nil { + return false, err + } + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && line == "" { + // EOF with no input: fall back to the default. + return defaultYes, nil + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "": + return defaultYes, nil + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + return false, nil + } +} + +// stdinIsInteractive reports whether in is an interactive terminal. It only +// treats the real os.Stdin as potentially interactive; a piped reader or test +// buffer is non-interactive. +func stdinIsInteractive(in io.Reader) bool { + f, ok := in.(*os.File) + if !ok { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/backend/internal/cli/import_test.go b/backend/internal/cli/import_test.go new file mode 100644 index 00000000..b15c1dbb --- /dev/null +++ b/backend/internal/cli/import_test.go @@ -0,0 +1,70 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" + "github.com/aoagents/agent-orchestrator/backend/internal/runfile" +) + +func writeLegacyProject(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) + } + if err := os.WriteFile(filepath.Join(root, "config.yaml"), + []byte("projects:\n alpha:\n path: /repos/alpha\n"), 0o600); err != nil { + t.Fatal(err) + } + return root +} + +func TestImportCommand_NoLegacyData(t *testing.T) { + setConfigEnv(t) + empty := filepath.Join(t.TempDir(), "nope") + out, _, err := executeCLI(t, Deps{}, "import", "--from", empty, "--yes") + if err != nil { + t.Fatalf("import: %v", err) + } + if !strings.Contains(out, "Nothing to import") { + t.Fatalf("out = %q, want 'Nothing to import'", out) + } +} + +func TestImportCommand_ImportsProjectJSON(t *testing.T) { + setConfigEnv(t) + root := writeLegacyProject(t) + + out, _, err := executeCLI(t, Deps{}, "import", "--from", root, "--yes", "--json") + if err != nil { + t.Fatalf("import: %v", err) + } + var rep legacyimport.Report + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("parse report %q: %v", out, err) + } + if rep.ProjectsImported != 1 { + t.Fatalf("projectsImported = %d, want 1", rep.ProjectsImported) + } +} + +func TestImportCommand_RefusesWhenDaemonRunning(t *testing.T) { + cfg := setConfigEnv(t) + root := writeLegacyProject(t) + + // A run-file owned by this (alive) process makes the daemon look live. + if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: 3001, StartedAt: time.Now()}); err != nil { + t.Fatalf("write run-file: %v", err) + } + + _, _, err := executeCLI(t, Deps{}, "import", "--from", root, "--yes") + if err == nil || !strings.Contains(err.Error(), "daemon is running") { + t.Fatalf("err = %v, want refusal because daemon is running", err) + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index f536459c..f0198fee 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -168,6 +168,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSendCommand(ctx)) root.AddCommand(newHooksCommand(ctx)) + root.AddCommand(newImportCommand(ctx)) root.AddCommand(newProjectCommand(ctx)) root.AddCommand(newSessionCommand(ctx)) root.AddCommand(newOrchestratorCommand(ctx)) diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index a67e4007..cd45fbfd 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -10,7 +10,9 @@ 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 @@ -74,6 +76,12 @@ 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) + exe, err := c.deps.Executable() if err != nil { return daemonStatus{}, fmt.Errorf("resolve executable: %w", err) @@ -109,6 +117,49 @@ 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/legacyimport/claude.go b/backend/internal/legacyimport/claude.go new file mode 100644 index 00000000..94ba32dd --- /dev/null +++ b/backend/internal/legacyimport/claude.go @@ -0,0 +1,101 @@ +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. +// +// The source slug realpath-resolves the legacy worktree (it exists on disk). +// The destination slug uses the LITERAL orchestrator-worktree path the rewrite +// will materialise on first resume — +// {dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator — with NO +// realpath, because that directory does not exist yet and ~/.ao/data is not a +// symlink, so the literal-path slug matches what Claude will compute from the +// resumed orchestrator's cwd (gitworktree managedPath, kind orchestrator). +func planTranscriptCopy(dataDir, projectID, prefix, worktree, uuid, claudeProjectsDir string) transcriptCopyPlan { + if claudeProjectsDir == "" { + claudeProjectsDir = defaultClaudeProjectsDir() + } + source := worktree + if resolved, err := filepath.EvalSymlinks(worktree); err == nil { + source = resolved + } + sourceSlug := claudeSlug(source) + + destTemplate := filepath.Join(dataDir, "worktrees", projectID, "orchestrator", prefix+"-orchestrator") + destSlug := claudeSlug(destTemplate) + + return transcriptCopyPlan{ + uuid: uuid, + sourcePath: filepath.Join(claudeProjectsDir, sourceSlug, uuid+".jsonl"), + destPath: filepath.Join(claudeProjectsDir, destSlug, uuid+".jsonl"), + } +} + +// 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 _, err := os.Stat(plan.destPath); err == nil { + return transcriptAlreadyPresent, nil + } + if _, err := os.Stat(plan.sourcePath); err != nil { + 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 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 new file mode 100644 index 00000000..4a729dcf --- /dev/null +++ b/backend/internal/legacyimport/claude_test.go @@ -0,0 +1,70 @@ +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 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 new file mode 100644 index 00000000..9633adcd --- /dev/null +++ b/backend/internal/legacyimport/config.go @@ -0,0 +1,126 @@ +package legacyimport + +import ( + "encoding/json" + "fmt" + "os" + + yaml "gopkg.in/yaml.v3" +) + +// legacyConfig is the subset of the legacy global config.yaml the importer +// reads: the projects registry keyed by project id. Unknown top-level keys +// (notifiers, power, plugins, …) are intentionally ignored — they have no home +// in the rewrite schema (issue #247 §4). +type legacyConfig struct { + Projects map[string]legacyProjectConfig `yaml:"projects"` +} + +// legacyProjectConfig is one project's block. Only the fields the rewrite can +// represent are typed; the rest are captured as raw nodes purely so the importer +// can report them as dropped (issue #247 §4). +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"` + Symlinks []string `yaml:"symlinks"` + PostCreate []string `yaml:"postCreate"` + AgentConfig *legacyAgentConfig `yaml:"agentConfig"` + Worker *legacyRole `yaml:"worker"` + Orchestrator *legacyRole `yaml:"orchestrator"` + + // Captured only to surface as dropped in the report (no rewrite home). + Tracker *yaml.Node `yaml:"tracker"` + SCM *yaml.Node `yaml:"scm"` + AgentRules *yaml.Node `yaml:"agentRules"` + AgentRulesFile *yaml.Node `yaml:"agentRulesFile"` + OrchestratorRule *yaml.Node `yaml:"orchestratorRules"` + Runtime *yaml.Node `yaml:"runtime"` + Workspace *yaml.Node `yaml:"workspace"` + Reactions *yaml.Node `yaml:"reactions"` +} + +type legacyAgentConfig struct { + Model string `yaml:"model"` + Permissions string `yaml:"permissions"` +} + +type legacyRole struct { + Agent string `yaml:"agent"` + AgentConfig *legacyAgentConfig `yaml:"agentConfig"` +} + +// loadLegacyConfig reads and parses root/config.yaml. A missing file is not an +// error — it yields an empty registry so the caller reports "nothing to import". +func loadLegacyConfig(root string) (legacyConfig, error) { + data, err := os.ReadFile(globalConfigPath(root)) + if os.IsNotExist(err) { + return legacyConfig{}, nil + } + if err != nil { + return legacyConfig{}, fmt.Errorf("read legacy config: %w", err) + } + var cfg legacyConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + } + return cfg, nil +} + +// preferences is the portfolio/preferences.json overlay: only per-project +// display names survive into the rewrite (issue #247 §1). +type preferences struct { + Projects map[string]struct { + DisplayName string `json:"displayName"` + } `json:"projects"` +} + +func loadPreferences(root string) preferences { + var p preferences + data, err := os.ReadFile(preferencesPath(root)) + if err != nil { + return p + } + _ = json.Unmarshal(data, &p) // best-effort overlay; a damaged file is ignored + return p +} + +// registeredManifest is the portfolio/registered.json overlay: it carries each +// project's addedAt, the best available registered_at provenance (issue #247 §1, +// G10). The legacy shape is a list of {id|path, addedAt} records. +type registeredManifest struct { + Projects []struct { + ID string `json:"id"` + Path string `json:"path"` + AddedAt string `json:"addedAt"` + } `json:"projects"` +} + +func loadRegistered(root string) registeredManifest { + var m registeredManifest + data, err := os.ReadFile(registeredPath(root)) + if err != nil { + return m + } + _ = json.Unmarshal(data, &m) + return m +} + +// addedAt returns the registration timestamp for a project, matching first by +// id then by path. "" when the manifest has no record. +func (m registeredManifest) addedAt(id, path string) string { + for _, p := range m.Projects { + if p.ID == id && p.AddedAt != "" { + return p.AddedAt + } + } + for _, p := range m.Projects { + if p.Path == path && p.AddedAt != "" { + return p.AddedAt + } + } + return "" +} diff --git a/backend/internal/legacyimport/importer.go b/backend/internal/legacyimport/importer.go new file mode 100644 index 00000000..67817168 --- /dev/null +++ b/backend/internal/legacyimport/importer.go @@ -0,0 +1,243 @@ +package legacyimport + +import ( + "context" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// 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. +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 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. + RepoOriginURL func(path string) string +} + +// Report is the structured outcome of an import run. +type Report struct { + DryRun bool `json:"dryRun"` + ProjectsImported int `json:"projectsImported"` + ProjectsSkipped int `json:"projectsSkipped"` // already present + OrchestratorsImported int `json:"orchestratorsImported"` + OrchestratorsSkipped int `json:"orchestratorsSkipped"` // terminal / non-importable / already present + OrchestratorsAbsent int `json:"orchestratorsAbsent"` + TranscriptsRelocated int `json:"transcriptsRelocated"` + Notes []string `json:"notes,omitempty"` +} + +// HasLegacyData reports whether root holds an importable legacy store: a +// config.yaml with at least one project. Used for the first-boot opt-in check. +func HasLegacyData(root string) bool { + if root == "" { + return false + } + cfg, err := loadLegacyConfig(root) + if err != nil { + return false + } + return len(cfg.Projects) > 0 +} + +// rewriteProjectID gates the rewrite project-id charset (validateProjectID, +// service.go). Legacy ids are a strict subset, so this all but always passes; +// it guards against a hand-edited legacy config carrying an illegal id. +var rewriteProjectID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +func isValidRewriteProjectID(id string) bool { + return id != "" && id != "." && !strings.Contains(id, "..") && + !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. +func Run(ctx context.Context, store Store, opts Options) (Report, error) { + root := opts.Root + if root == "" { + root = DefaultLegacyRootDir() + } + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + resolveOrigin := opts.RepoOriginURL + if resolveOrigin == nil { + resolveOrigin = defaultRepoOriginURL + } + + rep := Report{DryRun: opts.DryRun} + + cfg, err := loadLegacyConfig(root) + if err != nil { + return rep, err + } + if len(cfg.Projects) == 0 { + rep.Notes = append(rep.Notes, "no legacy projects found at "+root) + return rep, nil + } + + configMtime := "" + if info, err := os.Stat(globalConfigPath(root)); err == nil { + configMtime = info.ModTime().UTC().Format(time.RFC3339) + } + prefs := loadPreferences(root) + reg := loadRegistered(root) + + // Deterministic order: projects before sessions, ids sorted. + ids := make([]string, 0, len(cfg.Projects)) + for id := range cfg.Projects { + ids = append(ids, id) + } + sort.Strings(ids) + + deps := projectRowDeps{repoOriginURL: resolveOrigin, configMtime: configMtime, now: now} + + for _, id := range ids { + pc := cfg.Projects[id] + if !isValidRewriteProjectID(id) { + rep.Notes = append(rep.Notes, "project "+quote(id)+" skipped: id is not a valid rewrite project id") + continue + } + + record, notes := buildProjectRecord(id, pc, prefs, reg, deps) + rep.Notes = appendPrefixed(rep.Notes, id, notes) + + 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 +} + +func importProject(ctx context.Context, store Store, record domain.ProjectRecord, dryRun bool, rep *Report) error { + _, exists, err := store.GetProject(ctx, record.ID) + if err != nil { + return fmt.Errorf("lookup project %s: %w", record.ID, err) + } + if exists { + rep.ProjectsSkipped++ + return nil + } + if dryRun { + rep.ProjectsImported++ + return nil + } + if err := store.UpsertProject(ctx, record); err != nil { + return fmt.Errorf("write project %s: %w", record.ID, err) + } + rep.ProjectsImported++ + 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 + } + outcome, err := relocateTranscript(plan) + if err != nil { + rep.Notes = append(rep.Notes, mapping.projectID+": transcript relocation failed: "+err.Error()) + return nil // non-fatal: the orchestrator still resumes, just without prior context + } + if outcome == transcriptCopied { + rep.TranscriptsRelocated++ + } + return nil +} + +func appendPrefixed(dst []string, id string, notes []string) []string { + for _, n := range notes { + dst = append(dst, id+": "+n) + } + return dst +} + +// defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is +// absent or has no origin. Matches the rewrite's resolveGitOriginURL. +func defaultRepoOriginURL(path string) string { + if path == "" { + return "" + } + cmd := exec.Command("git", "-C", path, "remote", "get-url", "origin") + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/backend/internal/legacyimport/importer_test.go b/backend/internal/legacyimport/importer_test.go new file mode 100644 index 00000000..f0ce33af --- /dev/null +++ b/backend/internal/legacyimport/importer_test.go @@ -0,0 +1,202 @@ +package legacyimport + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// 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{}} +} + +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) 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) { + 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")) + + mustWrite(t, filepath.Join(root, "config.yaml"), `projects: + alpha: + path: /repos/alpha + name: Alpha + defaultBranch: develop + 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 +} + +func runOpts(root, claudeDir 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 "" }, + } +} + +func TestRun_EndToEnd(t *testing.T) { + root, claudeDir := writeLegacyRoot(t) + store := newFakeStore() + ctx := context.Background() + + rep, err := Run(ctx, store, runOpts(root, claudeDir)) + 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) + } +} + +func TestRun_Idempotent(t *testing.T) { + root, claudeDir := writeLegacyRoot(t) + store := newFakeStore() + ctx := context.Background() + if _, err := Run(ctx, store, runOpts(root, claudeDir)); err != nil { + t.Fatalf("first run: %v", err) + } + rep, err := Run(ctx, store, runOpts(root, claudeDir)) + 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) + store := newFakeStore() + opts := runOpts(root, claudeDir) + 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 { + t.Fatalf("dry-run plan = %+v", rep) + } + if len(store.projects) != 0 || len(store.sessions) != 0 { + t.Fatal("dry run must not write to the store") + } +} + +func TestRun_NoLegacyData(t *testing.T) { + root := filepath.Join(t.TempDir(), "empty") + rep, err := Run(context.Background(), newFakeStore(), Options{Root: root}) + if err != nil { + t.Fatalf("run: %v", err) + } + if rep.ProjectsImported != 0 || len(rep.Notes) == 0 { + t.Fatalf("expected empty import with a note, got %+v", rep) + } +} + +func TestHasLegacyData(t *testing.T) { + root, _ := writeLegacyRoot(t) + if !HasLegacyData(root) { + t.Fatal("HasLegacyData = false, want true") + } + if HasLegacyData(filepath.Join(t.TempDir(), "nope")) { + t.Fatal("HasLegacyData = true for missing root") + } +} + +func mustMkdir(t *testing.T, p string) { + t.Helper() + if err := os.MkdirAll(p, 0o750); err != nil { + t.Fatal(err) + } +} + +func mustWrite(t *testing.T, p, content string) { + t.Helper() + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/legacyimport/orchestrator.go b/backend/internal/legacyimport/orchestrator.go new file mode 100644 index 00000000..52ab0d7e --- /dev/null +++ b/backend/internal/legacyimport/orchestrator.go @@ -0,0 +1,340 @@ +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 "" +} + +// 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 && asString(raw["stateVersion"]) == "2" { + 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. + if agent == "codex" { + if m := asString(raw["codexModel"]); m != "" { + base.note = "codexModel " + quote(m) + " dropped (no rewrite column; codex resumes by thread id)" + } + } + if r := asString(raw["restoreFallbackReason"]); r != "" { + base.note = strings.TrimSpace(base.note + " restoreFallbackReason dropped (forensic only)") + } + + // 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 new file mode 100644 index 00000000..ef8b4729 --- /dev/null +++ b/backend/internal/legacyimport/orchestrator_test.go @@ -0,0 +1,156 @@ +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_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 new file mode 100644 index 00000000..f1150ead --- /dev/null +++ b/backend/internal/legacyimport/paths.go @@ -0,0 +1,95 @@ +// 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. +// +// 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 +// files are NEVER modified: a declined or failed import loses nothing, and a +// re-run skips rows that already exist. +package legacyimport + +import ( + "os" + "path/filepath" + "strings" +) + +// userHomeDir is indirected so tests can pin the home directory without mutating +// process environment. +var userHomeDir = os.UserHomeDir + +// DefaultLegacyRootDir returns the canonical legacy state root, +// ~/.agent-orchestrator, or "" when the home directory cannot be resolved. +func DefaultLegacyRootDir() string { + home, err := userHomeDir() + if err != nil { + return "" + } + 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") +} + +// preferencesPath / registeredPath are the optional portfolio overlays that +// carry UI display names and per-project registration timestamps. +func preferencesPath(root string) string { + return filepath.Join(root, "portfolio", "preferences.json") +} + +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.go b/backend/internal/legacyimport/project.go new file mode 100644 index 00000000..0151e512 --- /dev/null +++ b/backend/internal/legacyimport/project.go @@ -0,0 +1,212 @@ +package legacyimport + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// mapPermission maps a legacy AgentPermissionMode to the rewrite PermissionMode +// (issue #247 §3). ok=false means "unset" (no permission to carry); lossy=true +// flags a remap that drops a distinction the rewrite cannot represent. +func mapPermission(legacy string) (mode domain.PermissionMode, ok bool, lossy bool) { + switch legacy { + case "": + return "", false, false + case "permissionless", "skip": + // legacy already collapses skip→permissionless, but a hand-edited config + // could carry the raw value, so map it explicitly. + return domain.PermissionModeBypassPermissions, true, false + case "auto-edit": + return domain.PermissionModeAcceptEdits, true, false + case "default": + return domain.PermissionModeDefault, true, false + case "suggest": + // The rewrite has no suggest/plan mode (#247 G8). + return domain.PermissionModeDefault, true, true + default: + return domain.PermissionModeDefault, true, true + } +} + +// mapHarness maps a legacy agent plugin id to a rewrite harness, or ok=false +// when the rewrite has no such harness. +func mapHarness(agent string) (domain.AgentHarness, bool) { + if agent == "" { + return "", false + } + h := domain.AgentHarness(agent) + if h.IsKnown() { + return h, true + } + return "", false +} + +func buildAgentConfig(src *legacyAgentConfig, notes *[]string, label string) domain.AgentConfig { + var out domain.AgentConfig + if src == nil { + return out + } + if m := strings.TrimSpace(src.Model); m != "" { + out.Model = m + } + if mode, ok, lossy := mapPermission(src.Permissions); ok { + out.Permissions = mode + if lossy { + *notes = append(*notes, fmt.Sprintf("%s permission %q mapped lossily to %q", label, src.Permissions, mode)) + } + } + return out +} + +func buildRoleOverride(src *legacyRole, notes *[]string, label string) domain.RoleOverride { + var out domain.RoleOverride + if src == nil { + return out + } + if src.Agent != "" { + if h, ok := mapHarness(src.Agent); ok { + out.Harness = h + } else { + *notes = append(*notes, fmt.Sprintf("%s agent %q has no rewrite harness — dropped", label, src.Agent)) + } + } + out.AgentConfig = buildAgentConfig(src.AgentConfig, notes, label+" agent") + return out +} + +// buildProjectConfig maps a legacy project block to the typed rewrite config +// blob (issue #247 §3). It appends lossy/dropped notes and returns a config that +// may be IsZero (the store persists SQL NULL for that). +func buildProjectConfig(pc legacyProjectConfig, notes *[]string) domain.ProjectConfig { + var cfg domain.ProjectConfig + + // defaultBranch: omit "main" so the common case keeps config NULL. + if b := strings.TrimSpace(pc.DefaultBranch); b != "" && b != domain.DefaultBranchName { + cfg.DefaultBranch = b + } + if pc.SessionPrefix != "" { + cfg.SessionPrefix = pc.SessionPrefix + } + if len(pc.Env) > 0 { + cfg.Env = make(map[string]string, len(pc.Env)) + for k, v := range pc.Env { + cfg.Env[k] = v + } + } + if len(pc.Symlinks) > 0 { + cfg.Symlinks = append([]string(nil), pc.Symlinks...) + } + if len(pc.PostCreate) > 0 { + cfg.PostCreate = append([]string(nil), pc.PostCreate...) + } + cfg.AgentConfig = buildAgentConfig(pc.AgentConfig, notes, "agentConfig") + cfg.Worker = buildRoleOverride(pc.Worker, notes, "worker") + cfg.Orchestrator = buildRoleOverride(pc.Orchestrator, notes, "orchestrator") + + // Surface project-level fields the rewrite has no home for (#247 §4). + var dropped []string + if pc.Tracker != nil { + dropped = append(dropped, "tracker") + } + if pc.SCM != nil { + dropped = append(dropped, "scm") + } + if pc.AgentRules != nil || pc.AgentRulesFile != nil || pc.OrchestratorRule != nil { + dropped = append(dropped, "rules") + } + if pc.Runtime != nil { + dropped = append(dropped, "runtime") + } + if pc.Workspace != nil { + dropped = append(dropped, "workspace") + } + if pc.Reactions != nil { + dropped = append(dropped, "reactions") + } + if len(dropped) > 0 { + *notes = append(*notes, "dropped project fields with no rewrite home: "+strings.Join(dropped, ", ")) + } + return cfg +} + +// projectRowDeps are the host effects the project mapper needs: git origin +// resolution and the fallback "now" timestamp. Injected so the mapper is pure +// and unit-testable. +type projectRowDeps struct { + repoOriginURL func(path string) string + configMtime string // ISO timestamp of config.yaml, or "" if unknown + now time.Time +} + +// buildProjectRecord builds the rewrite projects row for one legacy project +// (issue #247 §1). The rewrite no longer fills server-side fields, so the +// importer computes repo_origin_url, registered_at, kind, display_name, config. +func buildProjectRecord(id string, pc legacyProjectConfig, prefs preferences, reg registeredManifest, deps projectRowDeps) (domain.ProjectRecord, []string) { + var notes []string + cfg := buildProjectConfig(pc, ¬es) + + path := normalizePath(pc.Path) + + // display_name: preferences.displayName → config name → "" (rewrite falls + // back to id on read, so only persist a real, non-id name). + displayName := "" + if p, ok := prefs.Projects[id]; ok && p.DisplayName != "" { + displayName = p.DisplayName + } else if pc.Name != "" && pc.Name != id { + displayName = pc.Name + } + + // registered_at: registered.json addedAt → config mtime → import time. + registeredAt := deps.now + if iso := reg.addedAt(id, pc.Path); iso != "" { + if t, err := time.Parse(time.RFC3339, iso); err == nil { + registeredAt = t + } + } else if deps.configMtime != "" { + if t, err := time.Parse(time.RFC3339, deps.configMtime); err == nil { + registeredAt = t + } + } + + origin := "" + if deps.repoOriginURL != nil { + origin = deps.repoOriginURL(path) + } + + return domain.ProjectRecord{ + ID: id, + Path: path, + RepoOriginURL: origin, + DisplayName: displayName, + RegisteredAt: registeredAt, + Kind: domain.ProjectKindSingleRepo, + Config: cfg, + }, notes +} + +// normalizePath ~-expands then absolutises+cleans a legacy project path, matching +// the rewrite's normalizePath. A path that cannot be absolutised is returned +// cleaned but relative (best effort; the rewrite re-derives worktrees anyway). +func normalizePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if p == "~" || strings.HasPrefix(p, "~/") { + if home, err := userHomeDir(); err == nil { + if p == "~" { + p = home + } else { + p = filepath.Join(home, p[2:]) + } + } + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return filepath.Clean(p) +} diff --git a/backend/internal/legacyimport/project_test.go b/backend/internal/legacyimport/project_test.go new file mode 100644 index 00000000..48dee926 --- /dev/null +++ b/backend/internal/legacyimport/project_test.go @@ -0,0 +1,126 @@ +package legacyimport + +import ( + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestMapPermission(t *testing.T) { + cases := []struct { + in string + want domain.PermissionMode + ok bool + lossy bool + }{ + {"", "", false, false}, + {"permissionless", domain.PermissionModeBypassPermissions, true, false}, + {"skip", domain.PermissionModeBypassPermissions, true, false}, + {"auto-edit", domain.PermissionModeAcceptEdits, true, false}, + {"default", domain.PermissionModeDefault, true, false}, + {"suggest", domain.PermissionModeDefault, true, true}, + {"weird", domain.PermissionModeDefault, true, true}, + } + for _, c := range cases { + mode, ok, lossy := mapPermission(c.in) + if mode != c.want || ok != c.ok || lossy != c.lossy { + t.Fatalf("mapPermission(%q) = (%q,%v,%v), want (%q,%v,%v)", c.in, mode, ok, lossy, c.want, c.ok, c.lossy) + } + } +} + +func TestMapHarness(t *testing.T) { + if h, ok := mapHarness("claude-code"); !ok || h != domain.HarnessClaudeCode { + t.Fatalf("claude-code = (%q,%v)", h, ok) + } + if _, ok := mapHarness("nope"); ok { + t.Fatal("unknown harness must map to ok=false") + } + if _, ok := mapHarness(""); ok { + t.Fatal("empty harness must map to ok=false") + } +} + +func TestBuildProjectConfig_RemapAndOmitMain(t *testing.T) { + var notes []string + pc := legacyProjectConfig{ + DefaultBranch: "main", // omitted so config stays minimal + SessionPrefix: "px", + Env: map[string]string{"K": "V"}, + AgentConfig: &legacyAgentConfig{Model: "m", Permissions: "suggest"}, + Worker: &legacyRole{Agent: "codex"}, + Orchestrator: &legacyRole{Agent: "bogus"}, // no rewrite harness → dropped note + Tracker: nonNilNode(), + } + cfg := buildProjectConfig(pc, ¬es) + if cfg.DefaultBranch != "" { + t.Fatalf("defaultBranch = %q, want omitted for main", cfg.DefaultBranch) + } + if cfg.SessionPrefix != "px" || cfg.Env["K"] != "V" { + t.Fatalf("config = %+v", cfg) + } + if cfg.AgentConfig.Permissions != domain.PermissionModeDefault { + t.Fatalf("permissions = %q, want default (lossy from suggest)", cfg.AgentConfig.Permissions) + } + if cfg.Worker.Harness != domain.HarnessCodex { + t.Fatalf("worker harness = %q, want codex", cfg.Worker.Harness) + } + if cfg.Orchestrator.Harness != "" { + t.Fatalf("orchestrator harness = %q, want dropped (unknown)", cfg.Orchestrator.Harness) + } + if len(notes) == 0 { + t.Fatal("expected lossy/dropped notes") + } +} + +func TestBuildProjectConfig_NonMainBranchKept(t *testing.T) { + var notes []string + cfg := buildProjectConfig(legacyProjectConfig{DefaultBranch: "develop"}, ¬es) + if cfg.DefaultBranch != "develop" { + t.Fatalf("defaultBranch = %q, want develop", cfg.DefaultBranch) + } +} + +func TestBuildProjectRecord_DisplayNameAndRegisteredAt(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + prefs := preferences{Projects: map[string]struct { + DisplayName string `json:"displayName"` + }{"proj": {DisplayName: "Pretty"}}} + reg := registeredManifest{Projects: []struct { + ID string `json:"id"` + Path string `json:"path"` + AddedAt string `json:"addedAt"` + }{{ID: "proj", AddedAt: "2026-05-05T00:00:00Z"}}} + + deps := projectRowDeps{ + repoOriginURL: func(string) string { return "git@github.com:o/r.git" }, + now: now, + } + rec, _ := buildProjectRecord("proj", legacyProjectConfig{Path: "/repo", Name: "ignored"}, prefs, reg, deps) + if rec.DisplayName != "Pretty" { + t.Fatalf("displayName = %q, want Pretty (preferences win)", rec.DisplayName) + } + if rec.RepoOriginURL != "git@github.com:o/r.git" { + t.Fatalf("origin = %q", rec.RepoOriginURL) + } + if rec.RegisteredAt.Format(time.RFC3339) != "2026-05-05T00:00:00Z" { + t.Fatalf("registeredAt = %s, want addedAt", rec.RegisteredAt) + } + if rec.Kind != domain.ProjectKindSingleRepo { + t.Fatalf("kind = %s", rec.Kind) + } +} + +func TestBuildProjectRecord_DisplayNameFallbacks(t *testing.T) { + now := time.Now().UTC() + deps := projectRowDeps{now: now} + // No preferences, config name == id → empty display name (rewrite falls back to id). + rec, _ := buildProjectRecord("proj", legacyProjectConfig{Path: "/r", Name: "proj"}, preferences{}, registeredManifest{}, deps) + if rec.DisplayName != "" { + t.Fatalf("displayName = %q, want empty", rec.DisplayName) + } + if !rec.RegisteredAt.Equal(now) { + t.Fatalf("registeredAt = %s, want now fallback", rec.RegisteredAt) + } +} diff --git a/backend/internal/storage/sqlite/store/session_import_store.go b/backend/internal/storage/sqlite/store/session_import_store.go new file mode 100644 index 00000000..a6ef74b6 --- /dev/null +++ b/backend/internal/storage/sqlite/store/session_import_store.go @@ -0,0 +1,60 @@ +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 new file mode 100644 index 00000000..87c19bd8 --- /dev/null +++ b/backend/internal/storage/sqlite/store/session_import_store_test.go @@ -0,0 +1,58 @@ +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) + } +} From d191d67a0e2b2fe3a3ebdaa40c4fb1c5a0f15a1e Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 15:59:54 +0530 Subject: [PATCH 2/3] fix(import): resolve golangci-lint errcheck/gocritic/nilerr findings - start.go: check fmt.Fprint* returns in the first-boot import path - project.go: combine same-typed return params (gocritic paramTypeCombine) - claude.go: use a pathExists helper so a missing transcript source is a normal skip, not an err-then-return-nil (nilerr) - importer.go: fold best-effort transcript relocation into a switch so the non-fatal path no longer returns nil from an error branch (nilerr) Co-Authored-By: Claude Opus 4.8 --- backend/internal/cli/start.go | 6 +++--- backend/internal/legacyimport/claude.go | 9 +++++++-- backend/internal/legacyimport/importer.go | 13 +++++++------ backend/internal/legacyimport/project.go | 2 +- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index cd45fbfd..0dfb1be8 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -142,19 +142,19 @@ func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Co 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) + _, _ = 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.") + _, _ = 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) + _, _ = fmt.Fprintf(out, "Import failed: %v\nContinuing fresh; legacy data is untouched. Retry with `ao import`.\n", err) return } _ = writeImportSummary(out, rep) diff --git a/backend/internal/legacyimport/claude.go b/backend/internal/legacyimport/claude.go index 94ba32dd..dd69d22c 100644 --- a/backend/internal/legacyimport/claude.go +++ b/backend/internal/legacyimport/claude.go @@ -68,10 +68,10 @@ const ( // (source-missing). Only "copied" counts as a relocation. The legacy source is // never modified. func relocateTranscript(plan transcriptCopyPlan) (transcriptOutcome, error) { - if _, err := os.Stat(plan.destPath); err == nil { + if pathExists(plan.destPath) { return transcriptAlreadyPresent, nil } - if _, err := os.Stat(plan.sourcePath); err != nil { + if !pathExists(plan.sourcePath) { return transcriptSourceMissing, nil } if err := os.MkdirAll(filepath.Dir(plan.destPath), 0o750); err != nil { @@ -83,6 +83,11 @@ func relocateTranscript(plan transcriptCopyPlan) (transcriptOutcome, error) { 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 { diff --git a/backend/internal/legacyimport/importer.go b/backend/internal/legacyimport/importer.go index 67817168..cc3712f3 100644 --- a/backend/internal/legacyimport/importer.go +++ b/backend/internal/legacyimport/importer.go @@ -210,12 +210,13 @@ func importOrchestrator(ctx context.Context, store Store, mapping orchestratorMa } return nil } - outcome, err := relocateTranscript(plan) - if err != nil { - rep.Notes = append(rep.Notes, mapping.projectID+": transcript relocation failed: "+err.Error()) - return nil // non-fatal: the orchestrator still resumes, just without prior context - } - if outcome == transcriptCopied { + // 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 diff --git a/backend/internal/legacyimport/project.go b/backend/internal/legacyimport/project.go index 0151e512..6a55d9df 100644 --- a/backend/internal/legacyimport/project.go +++ b/backend/internal/legacyimport/project.go @@ -12,7 +12,7 @@ import ( // mapPermission maps a legacy AgentPermissionMode to the rewrite PermissionMode // (issue #247 §3). ok=false means "unset" (no permission to carry); lossy=true // flags a remap that drops a distinction the rewrite cannot represent. -func mapPermission(legacy string) (mode domain.PermissionMode, ok bool, lossy bool) { +func mapPermission(legacy string) (mode domain.PermissionMode, ok, lossy bool) { switch legacy { case "": return "", false, false From 0b2836eb35e9f40d04d32de5a7a8158e10cb849c Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 19:51:09 +0530 Subject: [PATCH 3/3] fix(import): resolve transcript dest path like the daemon; harden lifecycle parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups on the legacy importer: - claude.go: compute the Claude transcript DESTINATION slug from the symlink-resolved orchestrator worktree path (new resolvePhysical, mirroring gitworktree.physicalAbs), not the literal path. The daemon resolves that cwd through physicalAbs before `claude --resume` runs, so a literal-path slug missed the resume bucket whenever any component of AO_DATA_DIR was a symlink (custom data dir, macOS /tmp→/private/tmp, symlinked $HOME) — the orchestrator would have resumed without its prior context. Source slug now uses the same resolver for symmetry. - orchestrator.go: accept a numeric stateVersion (JSON 2 → float64) as well as the string "2" when falling back to statePayload, so a V2 record carried only in statePayload is not misparsed as stateless. - orchestrator.go: build the dropped-resume-metadata note as a joined list instead of string concatenation. Tests: added a symlinked-data-dir dest-slug test and a numeric-stateVersion fallback test. Gate green: `go build ./... && go test -race ./...` (1425). Co-Authored-By: Claude Opus 4.8 --- backend/internal/legacyimport/claude.go | 57 ++++++++++++++----- backend/internal/legacyimport/claude_test.go | 22 +++++++ backend/internal/legacyimport/orchestrator.go | 21 ++++++- .../legacyimport/orchestrator_test.go | 15 +++++ 4 files changed, 99 insertions(+), 16 deletions(-) diff --git a/backend/internal/legacyimport/claude.go b/backend/internal/legacyimport/claude.go index dd69d22c..0c78a31c 100644 --- a/backend/internal/legacyimport/claude.go +++ b/backend/internal/legacyimport/claude.go @@ -27,25 +27,28 @@ type transcriptCopyPlan struct { // planTranscriptCopy computes the source + destination transcript paths. // -// The source slug realpath-resolves the legacy worktree (it exists on disk). -// The destination slug uses the LITERAL orchestrator-worktree path the rewrite -// will materialise on first resume — -// {dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator — with NO -// realpath, because that directory does not exist yet and ~/.ao/data is not a -// symlink, so the literal-path slug matches what Claude will compute from the -// resumed orchestrator's cwd (gitworktree managedPath, kind orchestrator). +// 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() } - source := worktree - if resolved, err := filepath.EvalSymlinks(worktree); err == nil { - source = resolved - } - sourceSlug := claudeSlug(source) + sourceSlug := claudeSlug(resolvePhysical(worktree)) destTemplate := filepath.Join(dataDir, "worktrees", projectID, "orchestrator", prefix+"-orchestrator") - destSlug := claudeSlug(destTemplate) + destSlug := claudeSlug(resolvePhysical(destTemplate)) return transcriptCopyPlan{ uuid: uuid, @@ -54,6 +57,34 @@ func planTranscriptCopy(dataDir, projectID, prefix, worktree, uuid, claudeProjec } } +// 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 diff --git a/backend/internal/legacyimport/claude_test.go b/backend/internal/legacyimport/claude_test.go index 4a729dcf..49ff8044 100644 --- a/backend/internal/legacyimport/claude_test.go +++ b/backend/internal/legacyimport/claude_test.go @@ -62,6 +62,28 @@ func TestRelocateTranscript_CopiesAndIsIdempotent(t *testing.T) { } } +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 { diff --git a/backend/internal/legacyimport/orchestrator.go b/backend/internal/legacyimport/orchestrator.go index 52ab0d7e..ec55d5c0 100644 --- a/backend/internal/legacyimport/orchestrator.go +++ b/backend/internal/legacyimport/orchestrator.go @@ -75,6 +75,19 @@ func asString(v any) string { 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 @@ -86,7 +99,7 @@ type legacyLifecycle struct { // when stateVersion == "2" (mirrors parseLifecycleField). func extractLifecycle(raw map[string]any) (legacyLifecycle, bool) { lc := asObject(raw["lifecycle"]) - if lc == nil && asString(raw["stateVersion"]) == "2" { + if lc == nil && isStateVersion2(raw["stateVersion"]) { lc = asObject(raw["statePayload"]) } if lc == nil { @@ -195,14 +208,16 @@ func mapOrchestratorRecord(raw map[string]any, projectID, prefix string, fileMti 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 != "" { - base.note = "codexModel " + quote(m) + " dropped (no rewrite column; codex resumes by thread id)" + dropped = append(dropped, "codexModel "+quote(m)+" dropped (no rewrite column; codex resumes by thread id)") } } if r := asString(raw["restoreFallbackReason"]); r != "" { - base.note = strings.TrimSpace(base.note + " restoreFallbackReason dropped (forensic only)") + 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). diff --git a/backend/internal/legacyimport/orchestrator_test.go b/backend/internal/legacyimport/orchestrator_test.go index ef8b4729..8e5b6305 100644 --- a/backend/internal/legacyimport/orchestrator_test.go +++ b/backend/internal/legacyimport/orchestrator_test.go @@ -92,6 +92,21 @@ func TestMapOrchestrator_StatePayloadFallback(t *testing.T) { } } +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{