From 7295956520b8f0c6f586c4dde52ca3fa8e2cd423 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Fri, 26 Jun 2026 20:44:52 +0530 Subject: [PATCH] refactor(legacyimport): scope import to projects + settings only Remove orchestrator/transcript import code (orchestrator.go, claude.go, session_import_store.go and their tests). Trim Store, Options, Report to projects-only fields. Drop defaultClaudeProjectsDir and projectSessionsDir from paths.go. Add yaml.TypeError robustness in config.go. Update cli/import.go confirm prompt and summary. Update importer_test.go to projects-only assertions. Co-Authored-By: Claude Opus 4.8 --- backend/internal/cli/import.go | 22 +- backend/internal/legacyimport/claude.go | 137 ------- backend/internal/legacyimport/claude_test.go | 92 ----- backend/internal/legacyimport/config.go | 12 +- backend/internal/legacyimport/importer.go | 112 ++---- .../internal/legacyimport/importer_test.go | 100 +---- backend/internal/legacyimport/orchestrator.go | 355 ------------------ .../legacyimport/orchestrator_test.go | 171 --------- backend/internal/legacyimport/paths.go | 49 +-- backend/internal/legacyimport/project_test.go | 7 + .../sqlite/store/session_import_store.go | 60 --- .../sqlite/store/session_import_store_test.go | 58 --- 12 files changed, 70 insertions(+), 1105 deletions(-) delete mode 100644 backend/internal/legacyimport/claude.go delete mode 100644 backend/internal/legacyimport/claude_test.go delete mode 100644 backend/internal/legacyimport/orchestrator.go delete mode 100644 backend/internal/legacyimport/orchestrator_test.go delete mode 100644 backend/internal/storage/sqlite/store/session_import_store.go delete 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 index b809f8992..f25c9ec0d 100644 --- a/backend/internal/cli/import.go +++ b/backend/internal/cli/import.go @@ -27,12 +27,11 @@ func newImportCommand(ctx *commandContext) *cobra.Command { var opts importOptions cmd := &cobra.Command{ Use: "import", - Short: "Import projects and orchestrator sessions from a legacy AO install", + Short: "Import projects from a legacy AO install", Long: "Import reads the legacy Agent Orchestrator flat-file store " + - "(~/.agent-orchestrator) read-only and ports its projects, per-project " + - "settings, and each project's live orchestrator session into the rewrite " + - "database. Legacy files are never modified, and a re-run skips rows that " + - "already exist, so it is safe to run more than once.\n\n" + + "(~/.agent-orchestrator) read-only and ports its projects and per-project " + + "settings into the rewrite database. Legacy files are never modified, and " + + "a re-run skips rows that already exist, so it is safe to run more than once.\n\n" + "The daemon must be stopped: it is the sole writer of the database.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -71,7 +70,7 @@ func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error if !opts.dryRun && !opts.yes { ok, err := confirm(c.deps.In, cmd.OutOrStdout(), - fmt.Sprintf("Import projects and orchestrator sessions from %s?", root), true) + fmt.Sprintf("Import projects from %s?", root), true) if err != nil { return err } @@ -82,9 +81,8 @@ func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error } rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{ - Root: root, - DataDir: cfg.DataDir, - DryRun: opts.dryRun, + Root: root, + DryRun: opts.dryRun, }) if err != nil { return err @@ -112,11 +110,9 @@ func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, o func writeImportSummary(w io.Writer, rep legacyimport.Report) error { var b strings.Builder if rep.DryRun { - b.WriteString("Dry run — no changes written.\n") + b.WriteString("Dry run -- no changes written.\n") } - fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped) - fmt.Fprintf(&b, "Orchestrators: %d imported, %d skipped, %d absent\n", rep.OrchestratorsImported, rep.OrchestratorsSkipped, rep.OrchestratorsAbsent) - fmt.Fprintf(&b, "Transcripts: %d relocated\n", rep.TranscriptsRelocated) + fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped) if len(rep.Notes) > 0 { b.WriteString("\nNotes:\n") for _, n := range rep.Notes { diff --git a/backend/internal/legacyimport/claude.go b/backend/internal/legacyimport/claude.go deleted file mode 100644 index 0c78a31c3..000000000 --- a/backend/internal/legacyimport/claude.go +++ /dev/null @@ -1,137 +0,0 @@ -package legacyimport - -import ( - "io" - "os" - "path/filepath" - "regexp" -) - -// claudeSlugRE matches every character Claude Code replaces with "-" when it -// buckets a cwd's transcripts under ~/.claude/projects//. The rule -// (empirically verified, issue #2129 §9) is: realpath(cwd) with every char -// outside [a-zA-Z0-9-] replaced by "-". A leading "/" therefore becomes a -// leading "-". -var claudeSlugRE = regexp.MustCompile(`[^a-zA-Z0-9-]`) - -func claudeSlug(path string) string { - return claudeSlugRE.ReplaceAllString(path, "-") -} - -// transcriptCopyPlan is the resolved source + destination of a transcript copy. -type transcriptCopyPlan struct { - uuid string - sourcePath string // ~/.claude/projects//.jsonl - destPath string // ~/.claude/projects//.jsonl -} - -// planTranscriptCopy computes the source + destination transcript paths. -// -// Claude Code buckets a transcript under ~/.claude/projects// where the -// slug is derived from the REALPATH of the session's cwd. Both slugs are -// therefore computed from symlink-resolved paths: -// -// - source: the legacy worktree the orchestrator last ran in (exists on disk). -// - destination: the orchestrator worktree the rewrite materialises on first -// resume — {dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator. -// The daemon resolves that path through physicalAbs before cd-ing into it -// (gitworktree New + validateManagedPath), so we resolve it the same way; a -// literal-path slug would miss the resume bucket whenever any component of -// dataDir (e.g. a custom AO_DATA_DIR, or macOS /tmp → /private/tmp) is a -// symlink. The leaf does not exist yet, so resolvePhysical resolves the -// longest existing ancestor and appends the literal tail — exactly what the -// daemon's physicalAbs does. -func planTranscriptCopy(dataDir, projectID, prefix, worktree, uuid, claudeProjectsDir string) transcriptCopyPlan { - if claudeProjectsDir == "" { - claudeProjectsDir = defaultClaudeProjectsDir() - } - sourceSlug := claudeSlug(resolvePhysical(worktree)) - - destTemplate := filepath.Join(dataDir, "worktrees", projectID, "orchestrator", prefix+"-orchestrator") - destSlug := claudeSlug(resolvePhysical(destTemplate)) - - return transcriptCopyPlan{ - uuid: uuid, - sourcePath: filepath.Join(claudeProjectsDir, sourceSlug, uuid+".jsonl"), - destPath: filepath.Join(claudeProjectsDir, destSlug, uuid+".jsonl"), - } -} - -// resolvePhysical resolves path to an absolute, symlink-free path, mirroring the -// daemon's gitworktree.physicalAbs so the transcript destination slug matches the -// cwd the resumed orchestrator actually runs in. When the leaf does not exist -// yet it resolves the longest existing ancestor and appends the literal tail. -func resolvePhysical(path string) string { - abs, err := filepath.Abs(path) - if err != nil { - return filepath.Clean(path) - } - abs = filepath.Clean(abs) - if resolved, err := filepath.EvalSymlinks(abs); err == nil { - return filepath.Clean(resolved) - } - parent := filepath.Dir(abs) - base := filepath.Base(abs) - for parent != "." && parent != string(os.PathSeparator) { - if resolved, err := filepath.EvalSymlinks(parent); err == nil { - return filepath.Join(resolved, base) - } - base = filepath.Join(filepath.Base(parent), base) - parent = filepath.Dir(parent) - } - if resolved, err := filepath.EvalSymlinks(parent); err == nil { - return filepath.Join(resolved, base) - } - return abs -} - -// transcriptOutcome reports what relocateTranscript did. -type transcriptOutcome string - -const ( - transcriptCopied transcriptOutcome = "copied" - transcriptAlreadyPresent transcriptOutcome = "already-present" - transcriptSourceMissing transcriptOutcome = "source-missing" -) - -// relocateTranscript executes a transcript copy. Idempotent: an existing -// destination is left as-is (already-present); a missing source is skipped -// (source-missing). Only "copied" counts as a relocation. The legacy source is -// never modified. -func relocateTranscript(plan transcriptCopyPlan) (transcriptOutcome, error) { - if pathExists(plan.destPath) { - return transcriptAlreadyPresent, nil - } - if !pathExists(plan.sourcePath) { - return transcriptSourceMissing, nil - } - if err := os.MkdirAll(filepath.Dir(plan.destPath), 0o750); err != nil { - return "", err - } - if err := copyFile(plan.sourcePath, plan.destPath); err != nil { - return "", err - } - return transcriptCopied, nil -} - -func pathExists(p string) bool { - _, err := os.Stat(p) - return err == nil -} - -func copyFile(src, dst string) error { - in, err := os.Open(src) //nolint:gosec // src is a resolved transcript path under ~/.claude - if err != nil { - return err - } - defer func() { _ = in.Close() }() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - return out.Close() -} diff --git a/backend/internal/legacyimport/claude_test.go b/backend/internal/legacyimport/claude_test.go deleted file mode 100644 index 49ff80443..000000000 --- a/backend/internal/legacyimport/claude_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package legacyimport - -import ( - "os" - "path/filepath" - "testing" - - yaml "gopkg.in/yaml.v3" -) - -func nonNilNode() *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Value: "x"} } - -func TestClaudeSlug(t *testing.T) { - if got := claudeSlug("/Users/me/Code/proj.x"); got != "-Users-me-Code-proj-x" { - t.Fatalf("slug = %q", got) - } -} - -func TestPlanTranscriptCopy_DestUsesOrchestratorTemplate(t *testing.T) { - plan := planTranscriptCopy("/data", "proj", "pre", "/legacy/wt", "uuid-1", "/claude") - // Destination slug = slug({dataDir}/worktrees/{projectID}/orchestrator/{prefix}-orchestrator). - wantDest := filepath.Join("/claude", claudeSlug("/data/worktrees/proj/orchestrator/pre-orchestrator"), "uuid-1.jsonl") - if plan.destPath != wantDest { - t.Fatalf("destPath = %q, want %q", plan.destPath, wantDest) - } -} - -func TestRelocateTranscript_CopiesAndIsIdempotent(t *testing.T) { - dir := t.TempDir() - claudeDir := filepath.Join(dir, "claude") - worktree := filepath.Join(dir, "wt") - if err := os.MkdirAll(worktree, 0o750); err != nil { - t.Fatal(err) - } - // Seed the legacy transcript at the source slug. planTranscriptCopy - // realpath-resolves the worktree, so seed under the resolved slug (matters on - // macOS where /var/folders is a symlink to /private/var/folders). - resolvedWt, err := filepath.EvalSymlinks(worktree) - if err != nil { - t.Fatal(err) - } - srcSlug := claudeSlug(resolvedWt) - srcDir := filepath.Join(claudeDir, srcSlug) - if err := os.MkdirAll(srcDir, 0o750); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(srcDir, "uuid-1.jsonl"), []byte("hello"), 0o600); err != nil { - t.Fatal(err) - } - - plan := planTranscriptCopy(filepath.Join(dir, "data"), "proj", "pre", worktree, "uuid-1", claudeDir) - out, err := relocateTranscript(plan) - if err != nil || out != transcriptCopied { - t.Fatalf("relocate = (%s,%v), want copied", out, err) - } - if b, err := os.ReadFile(plan.destPath); err != nil || string(b) != "hello" { - t.Fatalf("dest content = %q err=%v", b, err) - } - // Re-run: destination already present. - if out, _ := relocateTranscript(plan); out != transcriptAlreadyPresent { - t.Fatalf("second relocate = %s, want already-present", out) - } -} - -func TestPlanTranscriptCopy_DestResolvesSymlinkedDataDir(t *testing.T) { - // The daemon resolves the orchestrator worktree through physicalAbs before - // cd-ing into it, so the dest slug must use the symlink-resolved data dir — - // not the literal one — or `claude --resume` misses the bucket. - realData := t.TempDir() - linkDir := filepath.Join(t.TempDir(), "data-link") - if err := os.Symlink(realData, linkDir); err != nil { - t.Skipf("symlink unsupported: %v", err) - } - plan := planTranscriptCopy(linkDir, "proj", "pre", "/legacy/wt", "uuid-1", "/claude") - - resolvedReal, err := filepath.EvalSymlinks(realData) - if err != nil { - t.Fatal(err) - } - wantSlug := claudeSlug(filepath.Join(resolvedReal, "worktrees", "proj", "orchestrator", "pre-orchestrator")) - wantDest := filepath.Join("/claude", wantSlug, "uuid-1.jsonl") - if plan.destPath != wantDest { - t.Fatalf("destPath = %q,\n want %q (resolved, not the symlinked %q)", plan.destPath, wantDest, linkDir) - } -} - -func TestRelocateTranscript_SourceMissing(t *testing.T) { - plan := planTranscriptCopy(t.TempDir(), "proj", "pre", "/nope/wt", "uuid-x", filepath.Join(t.TempDir(), "claude")) - if out, err := relocateTranscript(plan); err != nil || out != transcriptSourceMissing { - t.Fatalf("relocate = (%s,%v), want source-missing", out, err) - } -} diff --git a/backend/internal/legacyimport/config.go b/backend/internal/legacyimport/config.go index 9633adcd9..bed1dabd6 100644 --- a/backend/internal/legacyimport/config.go +++ b/backend/internal/legacyimport/config.go @@ -2,6 +2,7 @@ package legacyimport import ( "encoding/json" + "errors" "fmt" "os" @@ -22,7 +23,9 @@ type legacyConfig struct { type legacyProjectConfig struct { Path string `yaml:"path"` Name string `yaml:"name"` - Repo string `yaml:"repo"` + // Repo is captured as a raw YAML node but never consumed; the origin URL is + // re-resolved from the repo path at import time. + Repo *yaml.Node `yaml:"repo"` DefaultBranch string `yaml:"defaultBranch"` SessionPrefix string `yaml:"sessionPrefix"` Env map[string]string `yaml:"env"` @@ -65,7 +68,12 @@ func loadLegacyConfig(root string) (legacyConfig, error) { } var cfg legacyConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err) + } + // A type mismatch (e.g. a scalar where a mapping is expected) is a + // partial decode: keep the decoded fields and continue. } return cfg, nil } diff --git a/backend/internal/legacyimport/importer.go b/backend/internal/legacyimport/importer.go index f10841259..ec637daf7 100644 --- a/backend/internal/legacyimport/importer.go +++ b/backend/internal/legacyimport/importer.go @@ -15,42 +15,31 @@ import ( // Store is the narrow slice of the rewrite's native storage layer the importer // writes through. *sqlite.Store satisfies it. Idempotency lives here: a project -// or orchestrator whose id already exists is skipped, never overwritten, so a -// re-run is safe and legacy files stay the sole source of truth. +// whose id already exists is skipped, never overwritten, so a re-run is safe +// and legacy files stay the sole source of truth. type Store interface { GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error) UpsertProject(ctx context.Context, r domain.ProjectRecord) error - GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) - ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error) } // Options configure one import run. type Options struct { // Root is the legacy state root to read (default ~/.agent-orchestrator). Root string - // DataDir is the rewrite data dir, used only to compute the destination - // transcript slug. It must match the daemon's AO_DATA_DIR. - DataDir string - // DryRun parses + plans every row and relocation but writes nothing. + // DryRun parses + plans every row but writes nothing. DryRun bool - // ClaudeProjectsDir overrides ~/.claude/projects (tests). - ClaudeProjectsDir string - // Now is the fallback registered_at timestamp. Zero → time.Now().UTC(). + // Now is the fallback registered_at timestamp. Zero -> time.Now().UTC(). Now time.Time - // RepoOriginURL resolves a repo's git origin. Nil → the real git resolver. + // RepoOriginURL resolves a repo's git origin. Nil -> the real git resolver. RepoOriginURL func(path string) string } // Report is the structured outcome of an import run. type Report struct { - DryRun bool `json:"dryRun"` - ProjectsImported int `json:"projectsImported"` - ProjectsSkipped int `json:"projectsSkipped"` // already present - OrchestratorsImported int `json:"orchestratorsImported"` - OrchestratorsSkipped int `json:"orchestratorsSkipped"` // terminal / non-importable / already present - OrchestratorsAbsent int `json:"orchestratorsAbsent"` - TranscriptsRelocated int `json:"transcriptsRelocated"` - Notes []string `json:"notes,omitempty"` + DryRun bool `json:"dryRun"` + ProjectsImported int `json:"projectsImported"` + ProjectsSkipped int `json:"projectsSkipped"` // already present + Notes []string `json:"notes,omitempty"` } // HasLegacyData reports whether root holds an importable legacy store: a @@ -76,11 +65,10 @@ func isValidRewriteProjectID(id string) bool { !strings.ContainsAny(id, `/\`) && rewriteProjectID.MatchString(id) } -// Run reads the legacy store and writes projects (then orchestrator sessions) -// into store, relocating claude-code transcripts. It never modifies legacy -// files. It is idempotent: existing rows are skipped. A per-project parse or -// write failure is recorded as a note and does not abort the whole run, except a -// store write error, which is returned. +// Run reads the legacy store and writes projects into store. It never modifies +// legacy files. It is idempotent: existing rows are skipped. A per-project +// parse or write failure is recorded as a note and does not abort the whole +// run, except a store write error, which is returned. func Run(ctx context.Context, store Store, opts Options) (Report, error) { root := opts.Root if root == "" { @@ -113,7 +101,7 @@ func Run(ctx context.Context, store Store, opts Options) (Report, error) { prefs := loadPreferences(root) reg := loadRegistered(root) - // Deterministic order: projects before sessions, ids sorted. + // Deterministic order: ids sorted. ids := make([]string, 0, len(cfg.Projects)) for id := range cfg.Projects { ids = append(ids, id) @@ -135,23 +123,6 @@ func Run(ctx context.Context, store Store, opts Options) (Report, error) { if err := importProject(ctx, store, record, opts.DryRun, &rep); err != nil { return rep, err } - - // Orchestrator session for this project. - sessionsDir := projectSessionsDir(root, id) - mapping := readOrchestratorMapping(sessionsDir, id, pc) - if mapping.note != "" { - rep.Notes = append(rep.Notes, id+": "+mapping.note) - } - switch mapping.status { - case orchAbsent: - rep.OrchestratorsAbsent++ - case orchSkipped: - rep.OrchestratorsSkipped++ - case orchMapped: - if err := importOrchestrator(ctx, store, mapping, opts, &rep); err != nil { - return rep, err - } - } } return rep, nil } @@ -176,52 +147,6 @@ func importProject(ctx context.Context, store Store, record domain.ProjectRecord return nil } -func importOrchestrator(ctx context.Context, store Store, mapping orchestratorMapping, opts Options, rep *Report) error { - rec := mapping.record - _, exists, err := store.GetSession(ctx, rec.ID) - if err != nil { - return fmt.Errorf("lookup orchestrator %s: %w", rec.ID, err) - } - if exists { - rep.OrchestratorsSkipped++ - } else if opts.DryRun { - rep.OrchestratorsImported++ - } else { - inserted, err := store.ImportSession(ctx, rec, 0) - if err != nil { - return fmt.Errorf("write orchestrator %s: %w", rec.ID, err) - } - if inserted { - rep.OrchestratorsImported++ - } else { - rep.OrchestratorsSkipped++ - } - } - - // Relocate the claude-code transcript (codex/opencode resume by global id). - if mapping.transcript == nil { - return nil - } - plan := planTranscriptCopy(opts.DataDir, mapping.projectID, mapping.prefix, - mapping.transcript.worktree, mapping.transcript.uuid, opts.ClaudeProjectsDir) - if opts.DryRun { - if _, err := os.Stat(plan.sourcePath); err == nil { - rep.TranscriptsRelocated++ - } - return nil - } - // Relocation is best-effort: a failure is noted, not fatal — the orchestrator - // still resumes, just without prior context. - outcome, relocErr := relocateTranscript(plan) - switch { - case relocErr != nil: - rep.Notes = append(rep.Notes, mapping.projectID+": transcript relocation failed: "+relocErr.Error()) - case outcome == transcriptCopied: - rep.TranscriptsRelocated++ - } - return nil -} - func appendPrefixed(dst []string, id string, notes []string) []string { for _, n := range notes { dst = append(dst, id+": "+n) @@ -229,6 +154,15 @@ func appendPrefixed(dst []string, id string, notes []string) []string { return dst } +// quote wraps s in double quotes for note messages, rendering an empty string as +// "?" so a missing value is still legible. +func quote(s string) string { + if s == "" { + return `"?"` + } + return `"` + s + `"` +} + // defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is // absent or has no origin. Matches the rewrite's resolveGitOriginURL. func defaultRepoOriginURL(path string) string { diff --git a/backend/internal/legacyimport/importer_test.go b/backend/internal/legacyimport/importer_test.go index f0ce33af0..44f76c31b 100644 --- a/backend/internal/legacyimport/importer_test.go +++ b/backend/internal/legacyimport/importer_test.go @@ -13,11 +13,10 @@ import ( // fakeStore is an in-memory Store with the importer's idempotency semantics. type fakeStore struct { projects map[string]domain.ProjectRecord - sessions map[domain.SessionID]domain.SessionRecord } func newFakeStore() *fakeStore { - return &fakeStore{projects: map[string]domain.ProjectRecord{}, sessions: map[domain.SessionID]domain.SessionRecord{}} + return &fakeStore{projects: map[string]domain.ProjectRecord{}} } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { @@ -28,25 +27,12 @@ func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) err f.projects[r.ID] = r return nil } -func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - r, ok := f.sessions[id] - return r, ok, nil -} -func (f *fakeStore) ImportSession(_ context.Context, rec domain.SessionRecord, _ int64) (bool, error) { - if _, ok := f.sessions[rec.ID]; ok { - return false, nil - } - f.sessions[rec.ID] = rec - return true, nil -} -// writeLegacyRoot builds a minimal legacy store: two projects, an importable -// claude-code orchestrator for alpha (with a seeded transcript), an aider -// orchestrator for beta (skipped). Returns the legacy root and the claude dir. -func writeLegacyRoot(t *testing.T) (root, claudeDir string) { +// writeLegacyRoot builds a minimal legacy store: two projects. Returns the +// legacy root. +func writeLegacyRoot(t *testing.T) string { t.Helper() - root = filepath.Join(t.TempDir(), ".agent-orchestrator") - claudeDir = filepath.Join(t.TempDir(), "claude") + root := filepath.Join(t.TempDir(), ".agent-orchestrator") mustMkdir(t, filepath.Join(root, "projects", "alpha", "sessions")) mustMkdir(t, filepath.Join(root, "projects", "beta", "sessions")) @@ -58,72 +44,29 @@ func writeLegacyRoot(t *testing.T) (root, claudeDir string) { beta: path: /repos/beta `) - - worktree := filepath.Join(t.TempDir(), "alpha-wt") - mustMkdir(t, worktree) - mustWrite(t, filepath.Join(root, "projects", "alpha", "sessions", "orchestrator.json"), `{ - "role": "orchestrator", - "agent": "claude-code", - "worktree": "`+worktree+`", - "claudeSessionUuid": "uuid-alpha", - "userPrompt": "go", - "createdAt": "2026-01-01T00:00:00Z", - "lifecycle": {"session": {"state": "working", "lastTransitionAt": "2026-01-02T00:00:00Z"}} - }`) - // Seed the transcript at the legacy source slug so relocation copies it - // (resolve symlinks to match planTranscriptCopy's realpath of the worktree). - resolvedWt, err := filepath.EvalSymlinks(worktree) - if err != nil { - t.Fatal(err) - } - srcDir := filepath.Join(claudeDir, claudeSlug(resolvedWt)) - mustMkdir(t, srcDir) - mustWrite(t, filepath.Join(srcDir, "uuid-alpha.jsonl"), "transcript") - - mustWrite(t, filepath.Join(root, "projects", "beta", "sessions", "orchestrator.json"), `{ - "role": "orchestrator", - "agent": "aider", - "lifecycle": {"session": {"state": "working"}} - }`) - return root, claudeDir + return root } -func runOpts(root, claudeDir string) Options { +func runOpts(root string) Options { return Options{ - Root: root, - DataDir: filepath.Join(filepath.Dir(root), "data"), - ClaudeProjectsDir: claudeDir, - Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), - RepoOriginURL: func(string) string { return "" }, + Root: root, + Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + RepoOriginURL: func(string) string { return "" }, } } func TestRun_EndToEnd(t *testing.T) { - root, claudeDir := writeLegacyRoot(t) + root := writeLegacyRoot(t) store := newFakeStore() ctx := context.Background() - rep, err := Run(ctx, store, runOpts(root, claudeDir)) + rep, err := Run(ctx, store, runOpts(root)) if err != nil { t.Fatalf("run: %v", err) } if rep.ProjectsImported != 2 { t.Fatalf("projectsImported = %d, want 2", rep.ProjectsImported) } - if rep.OrchestratorsImported != 1 { - t.Fatalf("orchestratorsImported = %d, want 1 (alpha)", rep.OrchestratorsImported) - } - if rep.OrchestratorsSkipped != 1 { - t.Fatalf("orchestratorsSkipped = %d, want 1 (beta/aider)", rep.OrchestratorsSkipped) - } - if rep.TranscriptsRelocated != 1 { - t.Fatalf("transcriptsRelocated = %d, want 1", rep.TranscriptsRelocated) - } - // The alpha orchestrator row landed verbatim. - o, ok := store.sessions["alpha-orchestrator"] - if !ok || o.Kind != domain.KindOrchestrator || o.Metadata.AgentSessionID != "uuid-alpha" { - t.Fatalf("alpha orchestrator = %+v ok=%v", o, ok) - } // develop branch survives into the config blob. if store.projects["alpha"].Config.DefaultBranch != "develop" { t.Fatalf("alpha config = %+v", store.projects["alpha"].Config) @@ -131,37 +74,34 @@ func TestRun_EndToEnd(t *testing.T) { } func TestRun_Idempotent(t *testing.T) { - root, claudeDir := writeLegacyRoot(t) + root := writeLegacyRoot(t) store := newFakeStore() ctx := context.Background() - if _, err := Run(ctx, store, runOpts(root, claudeDir)); err != nil { + if _, err := Run(ctx, store, runOpts(root)); err != nil { t.Fatalf("first run: %v", err) } - rep, err := Run(ctx, store, runOpts(root, claudeDir)) + rep, err := Run(ctx, store, runOpts(root)) if err != nil { t.Fatalf("second run: %v", err) } if rep.ProjectsImported != 0 || rep.ProjectsSkipped != 2 { t.Fatalf("re-run projects: imported=%d skipped=%d, want 0/2", rep.ProjectsImported, rep.ProjectsSkipped) } - if rep.OrchestratorsImported != 0 { - t.Fatalf("re-run orchestratorsImported = %d, want 0", rep.OrchestratorsImported) - } } func TestRun_DryRunWritesNothing(t *testing.T) { - root, claudeDir := writeLegacyRoot(t) + root := writeLegacyRoot(t) store := newFakeStore() - opts := runOpts(root, claudeDir) + opts := runOpts(root) opts.DryRun = true rep, err := Run(context.Background(), store, opts) if err != nil { t.Fatalf("dry run: %v", err) } - if rep.ProjectsImported != 2 || rep.OrchestratorsImported != 1 { + if rep.ProjectsImported != 2 { t.Fatalf("dry-run plan = %+v", rep) } - if len(store.projects) != 0 || len(store.sessions) != 0 { + if len(store.projects) != 0 { t.Fatal("dry run must not write to the store") } } @@ -178,7 +118,7 @@ func TestRun_NoLegacyData(t *testing.T) { } func TestHasLegacyData(t *testing.T) { - root, _ := writeLegacyRoot(t) + root := writeLegacyRoot(t) if !HasLegacyData(root) { t.Fatal("HasLegacyData = false, want true") } diff --git a/backend/internal/legacyimport/orchestrator.go b/backend/internal/legacyimport/orchestrator.go deleted file mode 100644 index ec55d5c0e..000000000 --- a/backend/internal/legacyimport/orchestrator.go +++ /dev/null @@ -1,355 +0,0 @@ -package legacyimport - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// migratableHarnesses are the orchestrator harnesses the importer ports. aider -// (and anything else) is skipped with a note (gist §6). -var migratableHarnesses = map[string]bool{ - "claude-code": true, - "codex": true, - "opencode": true, -} - -// terminalStates are the legacy canonical states that mean "do not import". -var terminalStates = map[string]bool{"done": true, "terminated": true} - -// orchestratorStatus is the outcome of mapping one project's orchestrator. -type orchestratorStatus string - -const ( - orchMapped orchestratorStatus = "mapped" - orchSkipped orchestratorStatus = "skipped" - orchAbsent orchestratorStatus = "absent" -) - -// transcriptRelocation carries the inputs to relocate a claude-code transcript. -type transcriptRelocation struct { - worktree string // legacy worktree path on disk (realpath-resolved by the relocator) - uuid string // claudeSessionUuid = the transcript filename stem -} - -// orchestratorMapping is the mapped orchestrator session plus its transcript -// relocation (claude-code only) and any skip/lossy note. -type orchestratorMapping struct { - projectID string - prefix string - status orchestratorStatus - record domain.SessionRecord // valid when status == orchMapped - transcript *transcriptRelocation - note string -} - -// asObject coerces a JSON value that may be an object OR a JSON-encoded string -// into a decoded map, mirroring the legacy reader's double-decode. -func asObject(v any) map[string]any { - switch t := v.(type) { - case map[string]any: - return t - case string: - s := strings.TrimSpace(t) - if s == "" { - return nil - } - var parsed any - if err := json.Unmarshal([]byte(s), &parsed); err == nil { - if m, ok := parsed.(map[string]any); ok { - return m - } - } - } - return nil -} - -func asString(v any) string { - if s, ok := v.(string); ok { - return s - } - return "" -} - -// isStateVersion2 reports whether a legacy stateVersion marks a V2 record. It -// accepts both the string "2" the legacy writer emits and a numeric 2, since -// JSON numbers decode to float64 through the untyped map. -func isStateVersion2(v any) bool { - switch t := v.(type) { - case string: - return t == "2" - case float64: - return t == 2 - } - return false -} - -// legacyLifecycle is the decoded session/runtime halves of the V2 lifecycle. -type legacyLifecycle struct { - session map[string]any - runtime map[string]any -} - -// extractLifecycle pulls the lifecycle, double-decoding stringified nested -// fields. It prefers the V2 "lifecycle" key, falling back to "statePayload" -// when stateVersion == "2" (mirrors parseLifecycleField). -func extractLifecycle(raw map[string]any) (legacyLifecycle, bool) { - lc := asObject(raw["lifecycle"]) - if lc == nil && isStateVersion2(raw["stateVersion"]) { - lc = asObject(raw["statePayload"]) - } - if lc == nil { - return legacyLifecycle{}, false - } - return legacyLifecycle{ - session: asObject(lc["session"]), - runtime: asObject(lc["runtime"]), - }, true -} - -// mapActivityState maps the legacy 8-state enum to a rewrite activity_state -// (issue #247 §2.1). Only non-terminal states reach here (terminal orchestrators -// are skipped upstream), so done/terminated need no mapping. -func mapActivityState(state string) domain.ActivityState { - switch state { - case "working": - return domain.ActivityActive - case "needs_input": - return domain.ActivityWaitingInput - default: - // not_started / idle / detecting / stuck / unknown → idle. - return domain.ActivityIdle - } -} - -// resumeID picks the rewrite agent_session_id by harness (issue #247 §2.2). -// codex carries codexModel and any harness may carry restoreFallbackReason in -// the legacy record; neither has a rewrite column (the single agent_session_id -// holds only the resume id), so both are dropped — the importer notes them. -func resumeID(harness string, raw map[string]any) string { - switch harness { - case "claude-code": - return asString(raw["claudeSessionUuid"]) - case "codex": - return asString(raw["codexThreadId"]) - case "opencode": - return asString(raw["opencodeSessionId"]) - default: - return "" - } -} - -// mapOrchestratorRecord maps a parsed legacy orchestrator record to a rewrite -// session record. Pure. fileMtime is the last-resort created_at when the record -// carries neither createdAt nor lifecycle.session.startedAt. -func mapOrchestratorRecord(raw map[string]any, projectID, prefix string, fileMtime time.Time) orchestratorMapping { - base := orchestratorMapping{projectID: projectID, prefix: prefix} - - lc, _ := extractLifecycle(raw) - state := asString(lc.session["state"]) - _, hasTerminatedAt := lc.session["terminatedAt"] - terminatedAtNonNull := hasTerminatedAt && lc.session["terminatedAt"] != nil - - // Import ONLY non-terminal, non-terminated orchestrators (gist §6). - if (state != "" && terminalStates[state]) || terminatedAtNonNull { - base.status = orchSkipped - base.note = "orchestrator is terminal (state=" + emptyDash(state) + ")" - return base - } - - agent := asString(raw["agent"]) - if !migratableHarnesses[agent] { - base.status = orchSkipped - base.note = "harness " + quote(agent) + " is not importable (only claude-code, codex, opencode)" - return base - } - - createdAt := firstTime(asString(raw["createdAt"]), asString(lc.session["startedAt"])) - if createdAt.IsZero() { - createdAt = fileMtime - } - activityLastAt := firstTime(asString(lc.session["lastTransitionAt"]), asString(lc.runtime["lastObservedAt"])) - if activityLastAt.IsZero() { - activityLastAt = createdAt - } - updatedAt := firstTime(asString(lc.session["lastTransitionAt"])) - if updatedAt.IsZero() { - updatedAt = createdAt - } - - worktree := asString(raw["worktree"]) - rec := domain.SessionRecord{ - ID: domain.SessionID(prefix + "-orchestrator"), - ProjectID: domain.ProjectID(projectID), - Kind: domain.KindOrchestrator, - Harness: domain.AgentHarness(agent), - DisplayName: asString(raw["displayName"]), - Activity: domain.Activity{ - State: mapActivityState(state), - LastActivityAt: activityLastAt, - }, - FirstSignalAt: activityLastAt, // backfill mirrors migration 0010 (#247 §2.1) - IsTerminated: false, - Metadata: domain.SessionMetadata{ - Branch: asString(raw["branch"]), - WorkspacePath: worktree, - AgentSessionID: resumeID(agent, raw), - Prompt: asString(raw["userPrompt"]), - }, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - base.status = orchMapped - base.record = rec - - // Note resume metadata the single agent_session_id column cannot hold. - var dropped []string - if agent == "codex" { - if m := asString(raw["codexModel"]); m != "" { - dropped = append(dropped, "codexModel "+quote(m)+" dropped (no rewrite column; codex resumes by thread id)") - } - } - if r := asString(raw["restoreFallbackReason"]); r != "" { - dropped = append(dropped, "restoreFallbackReason dropped (forensic only)") - } - base.note = strings.Join(dropped, "; ") - - // claude-code orchestrators carry a transcript to relocate (needs both a - // uuid and a worktree to compute source + destination slugs). - if agent == "claude-code" { - if uuid := asString(raw["claudeSessionUuid"]); uuid != "" && worktree != "" { - base.transcript = &transcriptRelocation{worktree: worktree, uuid: uuid} - } - } - return base -} - -// resolveOrchestratorPrefix resolves the import prefix: configured sessionPrefix, -// else the first 12 chars of the project id (matching the rewrite's -// resolvedSessionPrefix and the display-prefix convention). -func resolveOrchestratorPrefix(projectID string, pc legacyProjectConfig) string { - if p := strings.TrimSpace(pc.SessionPrefix); p != "" { - return p - } - if len(projectID) <= 12 { - return projectID - } - return projectID[:12] -} - -// parseJSONRecord parses JSON; nil on invalid/non-object content. -func parseJSONRecord(content string) map[string]any { - var parsed any - if err := json.Unmarshal([]byte(content), &parsed); err != nil { - return nil - } - if m, ok := parsed.(map[string]any); ok { - return m - } - return nil -} - -// findOrchestratorFile locates a project's orchestrator metadata file: the -// sessions-dir record whose raw role == "orchestrator", else the one named -// "{prefix}-orchestrator.json", else the legacy "orchestrator.json". Skips -// 0-byte and "*.corrupt-*" files (issue #2129 §8.1). -func findOrchestratorFile(sessionsDir, prefix string) string { - if sessionsDir == "" { - return "" - } - entries, err := os.ReadDir(sessionsDir) - if err != nil { - return "" - } - var byName string - for _, e := range entries { - name := e.Name() - if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.Contains(name, ".corrupt-") { - continue - } - file := filepath.Join(sessionsDir, name) - content, err := os.ReadFile(file) - if err != nil { - continue - } - trimmed := strings.TrimSpace(string(content)) - if trimmed == "" { - continue // 0-byte / reserved id - } - raw := parseJSONRecord(trimmed) - if raw == nil { - continue - } - if asString(raw["role"]) == "orchestrator" { - return file - } - if strings.TrimSuffix(name, ".json") == prefix+"-orchestrator" { - byName = file - } - } - if byName != "" { - return byName - } - // Defensive: the pre-V2 standalone orchestrator file. - legacy := filepath.Join(filepath.Dir(sessionsDir), "orchestrator.json") - if content, err := os.ReadFile(legacy); err == nil && strings.TrimSpace(string(content)) != "" { - return legacy - } - return "" -} - -// readOrchestratorMapping reads + maps a project's orchestrator. It returns -// absent when there is no orchestrator file, skipped for terminal/non-importable -// ones, and mapped (with the record and any transcript) otherwise. -func readOrchestratorMapping(sessionsDir, projectID string, pc legacyProjectConfig) orchestratorMapping { - prefix := resolveOrchestratorPrefix(projectID, pc) - file := findOrchestratorFile(sessionsDir, prefix) - if file == "" { - return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent} - } - content, err := os.ReadFile(file) - if err != nil { - return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent} - } - raw := parseJSONRecord(strings.TrimSpace(string(content))) - if raw == nil { - return orchestratorMapping{projectID: projectID, prefix: prefix, status: orchAbsent} - } - mtime := time.Unix(0, 0).UTC() - if info, err := os.Stat(file); err == nil { - mtime = info.ModTime().UTC() - } - return mapOrchestratorRecord(raw, projectID, prefix, mtime) -} - -// firstTime returns the first RFC3339-parseable timestamp, or zero time. -func firstTime(candidates ...string) time.Time { - for _, c := range candidates { - if c == "" { - continue - } - if t, err := time.Parse(time.RFC3339, c); err == nil { - return t.UTC() - } - } - return time.Time{} -} - -func emptyDash(s string) string { - if s == "" { - return "?" - } - return s -} - -func quote(s string) string { - if s == "" { - return `"?"` - } - return `"` + s + `"` -} diff --git a/backend/internal/legacyimport/orchestrator_test.go b/backend/internal/legacyimport/orchestrator_test.go deleted file mode 100644 index 8e5b6305d..000000000 --- a/backend/internal/legacyimport/orchestrator_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package legacyimport - -import ( - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func mtime() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) } - -func TestMapOrchestrator_ClaudeMapped(t *testing.T) { - raw := map[string]any{ - "agent": "claude-code", - "role": "orchestrator", - "branch": "main", - "worktree": "/legacy/wt", - "userPrompt": "orchestrate", - "displayName": "Orch", - "claudeSessionUuid": "uuid-123", - "createdAt": "2026-01-01T00:00:00Z", - "lifecycle": map[string]any{ - "session": map[string]any{ - "state": "working", - "lastTransitionAt": "2026-01-01T01:00:00Z", - }, - }, - } - m := mapOrchestratorRecord(raw, "proj", "proj", mtime()) - if m.status != orchMapped { - t.Fatalf("status = %s, want mapped (note=%q)", m.status, m.note) - } - r := m.record - if r.ID != "proj-orchestrator" || r.Kind != domain.KindOrchestrator { - t.Fatalf("id/kind = %s/%s", r.ID, r.Kind) - } - if r.Activity.State != domain.ActivityActive { - t.Fatalf("activity = %s, want active", r.Activity.State) - } - if r.Metadata.AgentSessionID != "uuid-123" { - t.Fatalf("agentSessionID = %q, want uuid-123", r.Metadata.AgentSessionID) - } - if r.CreatedAt.Format(time.RFC3339) != "2026-01-01T00:00:00Z" { - t.Fatalf("createdAt = %s", r.CreatedAt) - } - if m.transcript == nil || m.transcript.uuid != "uuid-123" || m.transcript.worktree != "/legacy/wt" { - t.Fatalf("transcript = %+v", m.transcript) - } -} - -func TestMapOrchestrator_DoubleDecodedLifecycle(t *testing.T) { - // lifecycle stored as a JSON-encoded string (legacy double-encoding). - raw := map[string]any{ - "agent": "codex", - "worktree": "/wt", - "createdAt": "2026-01-01T00:00:00Z", - "lifecycle": `{"session":{"state":"needs_input","lastTransitionAt":"2026-01-01T02:00:00Z"}}`, - "codexThreadId": "thread-9", - "codexModel": "o3", - } - m := mapOrchestratorRecord(raw, "p", "pre", mtime()) - if m.status != orchMapped { - t.Fatalf("status = %s", m.status) - } - if m.record.Activity.State != domain.ActivityWaitingInput { - t.Fatalf("state = %s, want waiting_input", m.record.Activity.State) - } - if m.record.Metadata.AgentSessionID != "thread-9" { - t.Fatalf("agentSessionID = %q, want thread-9", m.record.Metadata.AgentSessionID) - } - if m.transcript != nil { - t.Fatal("codex must not carry a transcript relocation") - } - if m.note == "" { - t.Fatal("expected a note about dropped codexModel") - } -} - -func TestMapOrchestrator_StatePayloadFallback(t *testing.T) { - raw := map[string]any{ - "agent": "opencode", - "stateVersion": "2", - "statePayload": map[string]any{"session": map[string]any{"state": "idle"}}, - "opencodeSessionId": "oc-1", - } - m := mapOrchestratorRecord(raw, "p", "p", mtime()) - if m.status != orchMapped || m.record.Activity.State != domain.ActivityIdle { - t.Fatalf("mapping = %+v", m) - } - if m.record.Metadata.AgentSessionID != "oc-1" { - t.Fatalf("agentSessionID = %q", m.record.Metadata.AgentSessionID) - } -} - -func TestMapOrchestrator_StatePayloadFallbackNumericVersion(t *testing.T) { - // stateVersion as a JSON number (decodes to float64) must still trigger the - // statePayload fallback. - raw := map[string]any{ - "agent": "opencode", - "stateVersion": float64(2), - "statePayload": map[string]any{"session": map[string]any{"state": "needs_input"}}, - "opencodeSessionId": "oc-2", - } - m := mapOrchestratorRecord(raw, "p", "p", mtime()) - if m.status != orchMapped || m.record.Activity.State != domain.ActivityWaitingInput { - t.Fatalf("mapping = %+v", m) - } -} - -func TestMapOrchestrator_SkipTerminal(t *testing.T) { - for _, st := range []string{"done", "terminated"} { - raw := map[string]any{ - "agent": "claude-code", - "lifecycle": map[string]any{"session": map[string]any{"state": st}}, - } - if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped { - t.Fatalf("state %s: status = %s, want skipped", st, m.status) - } - } -} - -func TestMapOrchestrator_SkipTerminatedAt(t *testing.T) { - raw := map[string]any{ - "agent": "claude-code", - "lifecycle": map[string]any{"session": map[string]any{ - "state": "working", "terminatedAt": "2026-01-01T00:00:00Z", - }}, - } - if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped { - t.Fatalf("status = %s, want skipped (terminatedAt set)", m.status) - } -} - -func TestMapOrchestrator_SkipAiderAndUnknown(t *testing.T) { - for _, agent := range []string{"aider", "grok", "", "bogus"} { - raw := map[string]any{ - "agent": agent, - "lifecycle": map[string]any{"session": map[string]any{"state": "working"}}, - } - if m := mapOrchestratorRecord(raw, "p", "p", mtime()); m.status != orchSkipped { - t.Fatalf("agent %q: status = %s, want skipped", agent, m.status) - } - } -} - -func TestMapOrchestrator_TimestampFallbacks(t *testing.T) { - // No createdAt/startedAt → file mtime; no lastTransitionAt → createdAt. - raw := map[string]any{ - "agent": "claude-code", - "lifecycle": map[string]any{"session": map[string]any{"state": "idle"}}, - } - m := mapOrchestratorRecord(raw, "p", "p", mtime()) - if !m.record.CreatedAt.Equal(mtime()) { - t.Fatalf("createdAt = %s, want file mtime", m.record.CreatedAt) - } - if !m.record.Activity.LastActivityAt.Equal(mtime()) { - t.Fatalf("activityLastAt = %s, want createdAt fallback", m.record.Activity.LastActivityAt) - } -} - -func TestResolveOrchestratorPrefix(t *testing.T) { - if got := resolveOrchestratorPrefix("short", legacyProjectConfig{}); got != "short" { - t.Fatalf("prefix = %q, want short", got) - } - if got := resolveOrchestratorPrefix("averylongprojectid", legacyProjectConfig{}); got != "averylongpro" { - t.Fatalf("prefix = %q, want first 12 chars", got) - } - if got := resolveOrchestratorPrefix("proj", legacyProjectConfig{SessionPrefix: "custom"}); got != "custom" { - t.Fatalf("prefix = %q, want custom", got) - } -} diff --git a/backend/internal/legacyimport/paths.go b/backend/internal/legacyimport/paths.go index f1150ead5..f79724f3f 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") @@ -55,40 +42,6 @@ func registeredPath(root string) string { return filepath.Join(root, "portfolio", "registered.json") } -// projectSessionsDir locates a project's sessions directory, accepting both the -// current layout (root/projects/{id}/sessions) and the older hashed layout -// (root/{hash}-{id}/sessions). It returns "" when neither exists. -func projectSessionsDir(root, projectID string) string { - primary := filepath.Join(root, "projects", projectID, "sessions") - if isDir(primary) { - return primary - } - // Older layout: a top-level "{hash}-{id}" directory. Match by the "-{id}" - // suffix; the id itself may contain "-", but the hashed form always prefixes - // it, so a suffix match is the faithful locator the legacy reader used. - entries, err := os.ReadDir(root) - if err != nil { - return "" - } - suffix := "-" + projectID - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() - if name == "projects" || name == "portfolio" { - continue - } - if strings.HasSuffix(name, suffix) { - cand := filepath.Join(root, name, "sessions") - if isDir(cand) { - return cand - } - } - } - return "" -} - func isDir(path string) bool { info, err := os.Stat(path) return err == nil && info.IsDir() diff --git a/backend/internal/legacyimport/project_test.go b/backend/internal/legacyimport/project_test.go index 48dee926a..ae69fca68 100644 --- a/backend/internal/legacyimport/project_test.go +++ b/backend/internal/legacyimport/project_test.go @@ -5,8 +5,15 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + yaml "gopkg.in/yaml.v3" ) +// nonNilNode returns a non-nil *yaml.Node for struct fields that are captured +// as raw nodes (tracker, scm, etc.), used to trigger the "dropped" note path. +func nonNilNode() *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Value: "x"} +} + func TestMapPermission(t *testing.T) { cases := []struct { in string diff --git a/backend/internal/storage/sqlite/store/session_import_store.go b/backend/internal/storage/sqlite/store/session_import_store.go deleted file mode 100644 index a6ef74b6b..000000000 --- a/backend/internal/storage/sqlite/store/session_import_store.go +++ /dev/null @@ -1,60 +0,0 @@ -package store - -import ( - "context" - "fmt" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -// ImportSession inserts a session with a caller-supplied id and num, bypassing -// CreateSession's per-project num generation so the legacy importer can preserve -// a verbatim id (e.g. "{prefix}-orchestrator", num 0). It is idempotent: an id -// that already exists is left untouched and inserted=false is returned, so a -// re-run of the importer never clobbers a row the daemon may since have evolved. -// -// Like CreateSession this is a single INSERT under writeMu; the ON CONFLICT -// guard makes the existence check and the insert atomic on the writer -// connection. It uses raw ExecContext to attach the ON CONFLICT clause the -// generated InsertSession query does not carry (the same raw-exec approach -// DeleteSession uses to work around sqlc's DELETE handling). -func (s *Store) ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error) { - activity := normalActivity(rec.Activity, rec.CreatedAt) - s.writeMu.Lock() - defer s.writeMu.Unlock() - res, err := s.writeDB.ExecContext(ctx, ` -INSERT INTO sessions ( - id, project_id, num, issue_id, kind, harness, display_name, - activity_state, activity_last_at, first_signal_at, is_terminated, - branch, workspace_path, runtime_handle_id, agent_session_id, prompt, - created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT(id) DO NOTHING`, - rec.ID, - rec.ProjectID, - num, - rec.IssueID, - rec.Kind, - rec.Harness, - rec.DisplayName, - activity.State, - activity.LastActivityAt, - timeToNullTime(rec.FirstSignalAt), - rec.IsTerminated, - rec.Metadata.Branch, - rec.Metadata.WorkspacePath, - rec.Metadata.RuntimeHandleID, - rec.Metadata.AgentSessionID, - rec.Metadata.Prompt, - rec.CreatedAt, - rec.UpdatedAt, - ) - if err != nil { - return false, fmt.Errorf("import session %s: %w", rec.ID, err) - } - n, err := res.RowsAffected() - if err != nil { - return false, fmt.Errorf("import session %s: rows affected: %w", rec.ID, err) - } - return n > 0, nil -} diff --git a/backend/internal/storage/sqlite/store/session_import_store_test.go b/backend/internal/storage/sqlite/store/session_import_store_test.go deleted file mode 100644 index 87c19bd82..000000000 --- a/backend/internal/storage/sqlite/store/session_import_store_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package store_test - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestImportSessionVerbatimAndIdempotent(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - seedProject(t, s, "mer") - - now := time.Now().UTC().Truncate(time.Second) - rec := domain.SessionRecord{ - ID: "mer-orchestrator", - ProjectID: "mer", - Kind: domain.KindOrchestrator, - Harness: domain.HarnessClaudeCode, - Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, - Metadata: domain.SessionMetadata{AgentSessionID: "uuid-1", Prompt: "go"}, - CreatedAt: now, - UpdatedAt: now, - } - - inserted, err := s.ImportSession(ctx, rec, 0) - if err != nil || !inserted { - t.Fatalf("first import: inserted=%v err=%v", inserted, err) - } - - got, ok, err := s.GetSession(ctx, "mer-orchestrator") - if err != nil || !ok { - t.Fatalf("get: ok=%v err=%v", ok, err) - } - if got.Kind != domain.KindOrchestrator || got.Metadata.AgentSessionID != "uuid-1" { - t.Fatalf("imported row = %+v", got) - } - - // Re-import is a no-op: the existing row is left untouched. - inserted, err = s.ImportSession(ctx, rec, 0) - if err != nil { - t.Fatalf("re-import err: %v", err) - } - if inserted { - t.Fatal("re-import reported inserted=true; want false (idempotent skip)") - } - - // num=0 leaves the next store-generated session at num=1 with no collision. - w, err := s.CreateSession(ctx, sampleRecord("mer")) - if err != nil { - t.Fatalf("create worker: %v", err) - } - if w.ID != "mer-1" { - t.Fatalf("worker id = %s, want mer-1 (orchestrator at num 0 must not collide)", w.ID) - } -}