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 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-26 20:44:52 +05:30
parent b71dba428e
commit 7295956520
No known key found for this signature in database
12 changed files with 70 additions and 1105 deletions

View File

@ -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 {

View File

@ -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/<slug>/. 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/<sourceSlug>/<uuid>.jsonl
destPath string // ~/.claude/projects/<destSlug>/<uuid>.jsonl
}
// planTranscriptCopy computes the source + destination transcript paths.
//
// Claude Code buckets a transcript under ~/.claude/projects/<slug>/ 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()
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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")
}

View File

@ -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 + `"`
}

View File

@ -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)
}
}

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}