feat(import): rewrite-side legacy → rewrite first-boot import (#314)
* feat(import): rewrite-side legacy → rewrite first-boot import Port the legacy-side TS reader (AgentWrapper #2144/#2129) to Go and run the migration inside the rewrite as an opt-in import, per the FINAL v2 plan. Reads the legacy flat-file store (~/.agent-orchestrator) read-only and writes the rewrite's own SQLite DB via the native storage layer; legacy files are never touched, and a re-run skips existing rows, so a declined or failed import loses nothing. What's included: - internal/legacyimport: Go reader + field mappers (issue #247). Lifecycle double-decode (lifecycle key or statePayload+stateVersion:"2"), role/orchestrator detection, sessionPrefix fallback (first 12 chars of id), 8→4 activity-state map, per-harness resume-id selection, permission/harness remap, and the claude transcript slug + relocation to the rewrite's orchestrator worktree path ({DataDir}/worktrees/{id}/orchestrator/{prefix}-orchestrator). - store.ImportSession: verbatim session insert (explicit id/num, ON CONFLICT DO NOTHING) so the orchestrator lands at id "{prefix}-orchestrator", num 0. - `ao import`: explicit, idempotent import with --from/--dry-run/--yes/--json. Refuses while a live daemon owns the run-file (the daemon is sole writer; the import runs offline, matching the #2129 reference). - First-boot opt-in: `ao start` offers the import before launching the daemon when legacy data is present and the rewrite DB has no projects yet. Declining or any failure is non-fatal; a non-interactive boot prints a hint instead of auto-importing. Scope (gist §6): all projects + per-project settings, and the single non-terminated orchestrator session per project (claude-code/codex/opencode; aider skipped with a note). Workers are not imported (they respawn fresh). Resume-id mapping (#247 §2.2): agent_session_id carries claudeSessionUuid / codexThreadId / opencodeSessionId by harness. codexModel and restoreFallbackReason have no rewrite column, so they are dropped and surfaced as import notes — codex resumes from the thread id alone, the rest is forensic. Gate: `go build ./... && go test -race ./...` green (1423 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(import): resolve golangci-lint errcheck/gocritic/nilerr findings - start.go: check fmt.Fprint* returns in the first-boot import path - project.go: combine same-typed return params (gocritic paramTypeCombine) - claude.go: use a pathExists helper so a missing transcript source is a normal skip, not an err-then-return-nil (nilerr) - importer.go: fold best-effort transcript relocation into a switch so the non-fatal path no longer returns nil from an error branch (nilerr) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(import): resolve transcript dest path like the daemon; harden lifecycle parse Code-review follow-ups on the legacy importer: - claude.go: compute the Claude transcript DESTINATION slug from the symlink-resolved orchestrator worktree path (new resolvePhysical, mirroring gitworktree.physicalAbs), not the literal path. The daemon resolves that cwd through physicalAbs before `claude --resume` runs, so a literal-path slug missed the resume bucket whenever any component of AO_DATA_DIR was a symlink (custom data dir, macOS /tmp→/private/tmp, symlinked $HOME) — the orchestrator would have resumed without its prior context. Source slug now uses the same resolver for symmetry. - orchestrator.go: accept a numeric stateVersion (JSON 2 → float64) as well as the string "2" when falling back to statePayload, so a V2 record carried only in statePayload is not misparsed as stateless. - orchestrator.go: build the dropped-resume-metadata note as a joined list instead of string concatenation. Tests: added a symlinked-data-dir dest-slug test and a numeric-stateVersion fallback test. Gate green: `go build ./... && go test -race ./...` (1425). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b37b6acefe
commit
5fff98087a
|
|
@ -0,0 +1,174 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
type importOptions struct {
|
||||
from string
|
||||
dryRun bool
|
||||
yes bool
|
||||
json bool
|
||||
}
|
||||
|
||||
func newImportCommand(ctx *commandContext) *cobra.Command {
|
||||
var opts importOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import projects and orchestrator sessions from a legacy AO install",
|
||||
Long: "Import reads the legacy Agent Orchestrator flat-file store " +
|
||||
"(~/.agent-orchestrator) read-only and ports its projects, per-project " +
|
||||
"settings, and each project's live orchestrator session into the rewrite " +
|
||||
"database. Legacy files are never modified, and a re-run skips rows that " +
|
||||
"already exist, so it is safe to run more than once.\n\n" +
|
||||
"The daemon must be stopped: it is the sole writer of the database.",
|
||||
Args: noArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return ctx.runImport(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.from, "from", "", "Legacy AO root to read (default ~/.agent-orchestrator)")
|
||||
cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Parse and report the planned import without writing")
|
||||
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt (for non-interactive use)")
|
||||
cmd.Flags().BoolVar(&opts.json, "json", false, "Output the import report as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The daemon is the sole writer; refuse to open the store underneath a live
|
||||
// one. A stale run-file (dead PID) is treated as safe.
|
||||
if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil {
|
||||
return fmt.Errorf("inspect run-file: %w", err)
|
||||
} else if live != nil {
|
||||
return usageError{fmt.Errorf("the AO daemon is running (pid %d); stop it first with `ao stop` before importing", live.PID)}
|
||||
}
|
||||
|
||||
root := opts.from
|
||||
if root == "" {
|
||||
root = legacyimport.DefaultLegacyRootDir()
|
||||
}
|
||||
if !legacyimport.HasLegacyData(root) {
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "No legacy AO projects found at %s. Nothing to import.\n", root)
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.dryRun && !opts.yes {
|
||||
ok, err := confirm(c.deps.In, cmd.OutOrStdout(),
|
||||
fmt.Sprintf("Import projects and orchestrator sessions from %s?", root), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), "Import cancelled.")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{
|
||||
Root: root,
|
||||
DataDir: cfg.DataDir,
|
||||
DryRun: opts.dryRun,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
return writeJSON(cmd.OutOrStdout(), rep)
|
||||
}
|
||||
return writeImportSummary(cmd.OutOrStdout(), rep)
|
||||
}
|
||||
|
||||
// executeImport opens the rewrite store, runs the import, and closes the store.
|
||||
// It is the one CLI path that opens the database directly: the import is a
|
||||
// one-time bootstrap that must run with the daemon stopped (guarded by the
|
||||
// caller), so it cannot go through the daemon's loopback API.
|
||||
func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, opts legacyimport.Options) (legacyimport.Report, error) {
|
||||
store, err := sqlite.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
return legacyimport.Report{}, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
return legacyimport.Run(ctx, store, opts)
|
||||
}
|
||||
|
||||
func writeImportSummary(w io.Writer, rep legacyimport.Report) error {
|
||||
var b strings.Builder
|
||||
if rep.DryRun {
|
||||
b.WriteString("Dry run — no changes written.\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped)
|
||||
fmt.Fprintf(&b, "Orchestrators: %d imported, %d skipped, %d absent\n", rep.OrchestratorsImported, rep.OrchestratorsSkipped, rep.OrchestratorsAbsent)
|
||||
fmt.Fprintf(&b, "Transcripts: %d relocated\n", rep.TranscriptsRelocated)
|
||||
if len(rep.Notes) > 0 {
|
||||
b.WriteString("\nNotes:\n")
|
||||
for _, n := range rep.Notes {
|
||||
fmt.Fprintf(&b, " - %s\n", n)
|
||||
}
|
||||
}
|
||||
_, err := io.WriteString(w, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// confirm prompts for a yes/no answer. When stdin is not an interactive
|
||||
// terminal it returns the default without prompting, so headless invocations
|
||||
// behave deterministically.
|
||||
func confirm(in io.Reader, out io.Writer, prompt string, defaultYes bool) (bool, error) {
|
||||
suffix := " [Y/n] "
|
||||
if !defaultYes {
|
||||
suffix = " [y/N] "
|
||||
}
|
||||
if !stdinIsInteractive(in) {
|
||||
return defaultYes, nil
|
||||
}
|
||||
if _, err := io.WriteString(out, prompt+suffix); err != nil {
|
||||
return false, err
|
||||
}
|
||||
line, err := bufio.NewReader(in).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
// EOF with no input: fall back to the default.
|
||||
return defaultYes, nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "":
|
||||
return defaultYes, nil
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// stdinIsInteractive reports whether in is an interactive terminal. It only
|
||||
// treats the real os.Stdin as potentially interactive; a piped reader or test
|
||||
// buffer is non-interactive.
|
||||
func stdinIsInteractive(in io.Reader) bool {
|
||||
f, ok := in.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
)
|
||||
|
||||
func writeLegacyProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), ".agent-orchestrator")
|
||||
if err := os.MkdirAll(filepath.Join(root, "projects", "alpha", "sessions"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "config.yaml"),
|
||||
[]byte("projects:\n alpha:\n path: /repos/alpha\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestImportCommand_NoLegacyData(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
empty := filepath.Join(t.TempDir(), "nope")
|
||||
out, _, err := executeCLI(t, Deps{}, "import", "--from", empty, "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "Nothing to import") {
|
||||
t.Fatalf("out = %q, want 'Nothing to import'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCommand_ImportsProjectJSON(t *testing.T) {
|
||||
setConfigEnv(t)
|
||||
root := writeLegacyProject(t)
|
||||
|
||||
out, _, err := executeCLI(t, Deps{}, "import", "--from", root, "--yes", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
var rep legacyimport.Report
|
||||
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||
t.Fatalf("parse report %q: %v", out, err)
|
||||
}
|
||||
if rep.ProjectsImported != 1 {
|
||||
t.Fatalf("projectsImported = %d, want 1", rep.ProjectsImported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCommand_RefusesWhenDaemonRunning(t *testing.T) {
|
||||
cfg := setConfigEnv(t)
|
||||
root := writeLegacyProject(t)
|
||||
|
||||
// A run-file owned by this (alive) process makes the daemon look live.
|
||||
if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: 3001, StartedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("write run-file: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := executeCLI(t, Deps{}, "import", "--from", root, "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "daemon is running") {
|
||||
t.Fatalf("err = %v, want refusal because daemon is running", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +168,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
|||
root.AddCommand(newSpawnCommand(ctx))
|
||||
root.AddCommand(newSendCommand(ctx))
|
||||
root.AddCommand(newHooksCommand(ctx))
|
||||
root.AddCommand(newImportCommand(ctx))
|
||||
root.AddCommand(newProjectCommand(ctx))
|
||||
root.AddCommand(newSessionCommand(ctx))
|
||||
root.AddCommand(newOrchestratorCommand(ctx))
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const defaultStartTimeout = 10 * time.Second
|
||||
|
|
@ -74,6 +76,12 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da
|
|||
}
|
||||
}
|
||||
|
||||
// First-boot opt-in: before launching the daemon (so the import runs with the
|
||||
// store unlocked and the daemon as sole writer afterwards), offer to import a
|
||||
// legacy AO install. Declining or any import failure is non-fatal — the
|
||||
// daemon still starts and the user can run `ao import` later.
|
||||
c.maybeFirstBootImport(ctx, cfg)
|
||||
|
||||
exe, err := c.deps.Executable()
|
||||
if err != nil {
|
||||
return daemonStatus{}, fmt.Errorf("resolve executable: %w", err)
|
||||
|
|
@ -109,6 +117,49 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da
|
|||
return ready, nil
|
||||
}
|
||||
|
||||
// maybeFirstBootImport offers to import a legacy AO install the first time the
|
||||
// daemon is started against an empty rewrite database. It is best-effort: every
|
||||
// failure path degrades to "start the daemon fresh" so a broken or absent legacy
|
||||
// store can never block startup. A non-interactive boot (Electron/headless)
|
||||
// never auto-imports; it prints a one-line hint to run `ao import` explicitly.
|
||||
func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Config) {
|
||||
root := legacyimport.DefaultLegacyRootDir()
|
||||
if !legacyimport.HasLegacyData(root) {
|
||||
return
|
||||
}
|
||||
|
||||
store, err := sqlite.Open(cfg.DataDir)
|
||||
if err != nil {
|
||||
return // the daemon will surface a real store error on its own open
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
projects, err := store.ListProjects(ctx)
|
||||
if err != nil || len(projects) > 0 {
|
||||
// Already imported (or populated) — don't offer again.
|
||||
return
|
||||
}
|
||||
|
||||
out := c.deps.Out
|
||||
if !stdinIsInteractive(c.deps.In) {
|
||||
_, _ = fmt.Fprintf(out, "Found existing AO projects at %s. Run `ao import` to bring them in.\n", root)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := confirm(c.deps.In, out, "Found existing AO projects and sessions. Import them now?", true)
|
||||
if err != nil || !ok {
|
||||
_, _ = fmt.Fprintln(out, "Continuing fresh. Run `ao import` later to bring in your existing data.")
|
||||
return
|
||||
}
|
||||
|
||||
rep, err := legacyimport.Run(ctx, store, legacyimport.Options{Root: root, DataDir: cfg.DataDir})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(out, "Import failed: %v\nContinuing fresh; legacy data is untouched. Retry with `ao import`.\n", err)
|
||||
return
|
||||
}
|
||||
_ = writeImportSummary(out, rep)
|
||||
}
|
||||
|
||||
func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration) (daemonStatus, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultStartTimeout
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package legacyimport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
yaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// legacyConfig is the subset of the legacy global config.yaml the importer
|
||||
// reads: the projects registry keyed by project id. Unknown top-level keys
|
||||
// (notifiers, power, plugins, …) are intentionally ignored — they have no home
|
||||
// in the rewrite schema (issue #247 §4).
|
||||
type legacyConfig struct {
|
||||
Projects map[string]legacyProjectConfig `yaml:"projects"`
|
||||
}
|
||||
|
||||
// legacyProjectConfig is one project's block. Only the fields the rewrite can
|
||||
// represent are typed; the rest are captured as raw nodes purely so the importer
|
||||
// can report them as dropped (issue #247 §4).
|
||||
type legacyProjectConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Repo string `yaml:"repo"`
|
||||
DefaultBranch string `yaml:"defaultBranch"`
|
||||
SessionPrefix string `yaml:"sessionPrefix"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
Symlinks []string `yaml:"symlinks"`
|
||||
PostCreate []string `yaml:"postCreate"`
|
||||
AgentConfig *legacyAgentConfig `yaml:"agentConfig"`
|
||||
Worker *legacyRole `yaml:"worker"`
|
||||
Orchestrator *legacyRole `yaml:"orchestrator"`
|
||||
|
||||
// Captured only to surface as dropped in the report (no rewrite home).
|
||||
Tracker *yaml.Node `yaml:"tracker"`
|
||||
SCM *yaml.Node `yaml:"scm"`
|
||||
AgentRules *yaml.Node `yaml:"agentRules"`
|
||||
AgentRulesFile *yaml.Node `yaml:"agentRulesFile"`
|
||||
OrchestratorRule *yaml.Node `yaml:"orchestratorRules"`
|
||||
Runtime *yaml.Node `yaml:"runtime"`
|
||||
Workspace *yaml.Node `yaml:"workspace"`
|
||||
Reactions *yaml.Node `yaml:"reactions"`
|
||||
}
|
||||
|
||||
type legacyAgentConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Permissions string `yaml:"permissions"`
|
||||
}
|
||||
|
||||
type legacyRole struct {
|
||||
Agent string `yaml:"agent"`
|
||||
AgentConfig *legacyAgentConfig `yaml:"agentConfig"`
|
||||
}
|
||||
|
||||
// loadLegacyConfig reads and parses root/config.yaml. A missing file is not an
|
||||
// error — it yields an empty registry so the caller reports "nothing to import".
|
||||
func loadLegacyConfig(root string) (legacyConfig, error) {
|
||||
data, err := os.ReadFile(globalConfigPath(root))
|
||||
if os.IsNotExist(err) {
|
||||
return legacyConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return legacyConfig{}, fmt.Errorf("read legacy config: %w", err)
|
||||
}
|
||||
var cfg legacyConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// preferences is the portfolio/preferences.json overlay: only per-project
|
||||
// display names survive into the rewrite (issue #247 §1).
|
||||
type preferences struct {
|
||||
Projects map[string]struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
|
||||
func loadPreferences(root string) preferences {
|
||||
var p preferences
|
||||
data, err := os.ReadFile(preferencesPath(root))
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
_ = json.Unmarshal(data, &p) // best-effort overlay; a damaged file is ignored
|
||||
return p
|
||||
}
|
||||
|
||||
// registeredManifest is the portfolio/registered.json overlay: it carries each
|
||||
// project's addedAt, the best available registered_at provenance (issue #247 §1,
|
||||
// G10). The legacy shape is a list of {id|path, addedAt} records.
|
||||
type registeredManifest struct {
|
||||
Projects []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
AddedAt string `json:"addedAt"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
|
||||
func loadRegistered(root string) registeredManifest {
|
||||
var m registeredManifest
|
||||
data, err := os.ReadFile(registeredPath(root))
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
_ = json.Unmarshal(data, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
// addedAt returns the registration timestamp for a project, matching first by
|
||||
// id then by path. "" when the manifest has no record.
|
||||
func (m registeredManifest) addedAt(id, path string) string {
|
||||
for _, p := range m.Projects {
|
||||
if p.ID == id && p.AddedAt != "" {
|
||||
return p.AddedAt
|
||||
}
|
||||
}
|
||||
for _, p := range m.Projects {
|
||||
if p.Path == path && p.AddedAt != "" {
|
||||
return p.AddedAt
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
package legacyimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// Store is the narrow slice of the rewrite's native storage layer the importer
|
||||
// writes through. *sqlite.Store satisfies it. Idempotency lives here: a project
|
||||
// or orchestrator whose id already exists is skipped, never overwritten, so a
|
||||
// re-run is safe and legacy files stay the sole source of truth.
|
||||
type Store interface {
|
||||
GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
|
||||
UpsertProject(ctx context.Context, r domain.ProjectRecord) error
|
||||
GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
|
||||
ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error)
|
||||
}
|
||||
|
||||
// Options configure one import run.
|
||||
type Options struct {
|
||||
// Root is the legacy state root to read (default ~/.agent-orchestrator).
|
||||
Root string
|
||||
// DataDir is the rewrite data dir, used only to compute the destination
|
||||
// transcript slug. It must match the daemon's AO_DATA_DIR.
|
||||
DataDir string
|
||||
// DryRun parses + plans every row and relocation but writes nothing.
|
||||
DryRun bool
|
||||
// ClaudeProjectsDir overrides ~/.claude/projects (tests).
|
||||
ClaudeProjectsDir string
|
||||
// Now is the fallback registered_at timestamp. Zero → time.Now().UTC().
|
||||
Now time.Time
|
||||
// RepoOriginURL resolves a repo's git origin. Nil → the real git resolver.
|
||||
RepoOriginURL func(path string) string
|
||||
}
|
||||
|
||||
// Report is the structured outcome of an import run.
|
||||
type Report struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
ProjectsImported int `json:"projectsImported"`
|
||||
ProjectsSkipped int `json:"projectsSkipped"` // already present
|
||||
OrchestratorsImported int `json:"orchestratorsImported"`
|
||||
OrchestratorsSkipped int `json:"orchestratorsSkipped"` // terminal / non-importable / already present
|
||||
OrchestratorsAbsent int `json:"orchestratorsAbsent"`
|
||||
TranscriptsRelocated int `json:"transcriptsRelocated"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// HasLegacyData reports whether root holds an importable legacy store: a
|
||||
// config.yaml with at least one project. Used for the first-boot opt-in check.
|
||||
func HasLegacyData(root string) bool {
|
||||
if root == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := loadLegacyConfig(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(cfg.Projects) > 0
|
||||
}
|
||||
|
||||
// rewriteProjectID gates the rewrite project-id charset (validateProjectID,
|
||||
// service.go). Legacy ids are a strict subset, so this all but always passes;
|
||||
// it guards against a hand-edited legacy config carrying an illegal id.
|
||||
var rewriteProjectID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
func isValidRewriteProjectID(id string) bool {
|
||||
return id != "" && id != "." && !strings.Contains(id, "..") &&
|
||||
!strings.ContainsAny(id, `/\`) && rewriteProjectID.MatchString(id)
|
||||
}
|
||||
|
||||
// Run reads the legacy store and writes projects (then orchestrator sessions)
|
||||
// into store, relocating claude-code transcripts. It never modifies legacy
|
||||
// files. It is idempotent: existing rows are skipped. A per-project parse or
|
||||
// write failure is recorded as a note and does not abort the whole run, except a
|
||||
// store write error, which is returned.
|
||||
func Run(ctx context.Context, store Store, opts Options) (Report, error) {
|
||||
root := opts.Root
|
||||
if root == "" {
|
||||
root = DefaultLegacyRootDir()
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
resolveOrigin := opts.RepoOriginURL
|
||||
if resolveOrigin == nil {
|
||||
resolveOrigin = defaultRepoOriginURL
|
||||
}
|
||||
|
||||
rep := Report{DryRun: opts.DryRun}
|
||||
|
||||
cfg, err := loadLegacyConfig(root)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if len(cfg.Projects) == 0 {
|
||||
rep.Notes = append(rep.Notes, "no legacy projects found at "+root)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
configMtime := ""
|
||||
if info, err := os.Stat(globalConfigPath(root)); err == nil {
|
||||
configMtime = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
prefs := loadPreferences(root)
|
||||
reg := loadRegistered(root)
|
||||
|
||||
// Deterministic order: projects before sessions, ids sorted.
|
||||
ids := make([]string, 0, len(cfg.Projects))
|
||||
for id := range cfg.Projects {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
deps := projectRowDeps{repoOriginURL: resolveOrigin, configMtime: configMtime, now: now}
|
||||
|
||||
for _, id := range ids {
|
||||
pc := cfg.Projects[id]
|
||||
if !isValidRewriteProjectID(id) {
|
||||
rep.Notes = append(rep.Notes, "project "+quote(id)+" skipped: id is not a valid rewrite project id")
|
||||
continue
|
||||
}
|
||||
|
||||
record, notes := buildProjectRecord(id, pc, prefs, reg, deps)
|
||||
rep.Notes = appendPrefixed(rep.Notes, id, notes)
|
||||
|
||||
if err := importProject(ctx, store, record, opts.DryRun, &rep); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// Orchestrator session for this project.
|
||||
sessionsDir := projectSessionsDir(root, id)
|
||||
mapping := readOrchestratorMapping(sessionsDir, id, pc)
|
||||
if mapping.note != "" {
|
||||
rep.Notes = append(rep.Notes, id+": "+mapping.note)
|
||||
}
|
||||
switch mapping.status {
|
||||
case orchAbsent:
|
||||
rep.OrchestratorsAbsent++
|
||||
case orchSkipped:
|
||||
rep.OrchestratorsSkipped++
|
||||
case orchMapped:
|
||||
if err := importOrchestrator(ctx, store, mapping, opts, &rep); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func importProject(ctx context.Context, store Store, record domain.ProjectRecord, dryRun bool, rep *Report) error {
|
||||
_, exists, err := store.GetProject(ctx, record.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup project %s: %w", record.ID, err)
|
||||
}
|
||||
if exists {
|
||||
rep.ProjectsSkipped++
|
||||
return nil
|
||||
}
|
||||
if dryRun {
|
||||
rep.ProjectsImported++
|
||||
return nil
|
||||
}
|
||||
if err := store.UpsertProject(ctx, record); err != nil {
|
||||
return fmt.Errorf("write project %s: %w", record.ID, err)
|
||||
}
|
||||
rep.ProjectsImported++
|
||||
return nil
|
||||
}
|
||||
|
||||
func importOrchestrator(ctx context.Context, store Store, mapping orchestratorMapping, opts Options, rep *Report) error {
|
||||
rec := mapping.record
|
||||
_, exists, err := store.GetSession(ctx, rec.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup orchestrator %s: %w", rec.ID, err)
|
||||
}
|
||||
if exists {
|
||||
rep.OrchestratorsSkipped++
|
||||
} else if opts.DryRun {
|
||||
rep.OrchestratorsImported++
|
||||
} else {
|
||||
inserted, err := store.ImportSession(ctx, rec, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write orchestrator %s: %w", rec.ID, err)
|
||||
}
|
||||
if inserted {
|
||||
rep.OrchestratorsImported++
|
||||
} else {
|
||||
rep.OrchestratorsSkipped++
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate the claude-code transcript (codex/opencode resume by global id).
|
||||
if mapping.transcript == nil {
|
||||
return nil
|
||||
}
|
||||
plan := planTranscriptCopy(opts.DataDir, mapping.projectID, mapping.prefix,
|
||||
mapping.transcript.worktree, mapping.transcript.uuid, opts.ClaudeProjectsDir)
|
||||
if opts.DryRun {
|
||||
if _, err := os.Stat(plan.sourcePath); err == nil {
|
||||
rep.TranscriptsRelocated++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is
|
||||
// absent or has no origin. Matches the rewrite's resolveGitOriginURL.
|
||||
func defaultRepoOriginURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
cmd := exec.Command("git", "-C", path, "remote", "get-url", "origin")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package legacyimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory Store with the importer's idempotency semantics.
|
||||
type fakeStore struct {
|
||||
projects map[string]domain.ProjectRecord
|
||||
sessions map[domain.SessionID]domain.SessionRecord
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{projects: map[string]domain.ProjectRecord{}, sessions: map[domain.SessionID]domain.SessionRecord{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
|
||||
r, ok := f.projects[id]
|
||||
return r, ok, nil
|
||||
}
|
||||
func (f *fakeStore) UpsertProject(_ context.Context, r domain.ProjectRecord) error {
|
||||
f.projects[r.ID] = r
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
|
||||
r, ok := f.sessions[id]
|
||||
return r, ok, nil
|
||||
}
|
||||
func (f *fakeStore) ImportSession(_ context.Context, rec domain.SessionRecord, _ int64) (bool, error) {
|
||||
if _, ok := f.sessions[rec.ID]; ok {
|
||||
return false, nil
|
||||
}
|
||||
f.sessions[rec.ID] = rec
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeLegacyRoot builds a minimal legacy store: two projects, an importable
|
||||
// claude-code orchestrator for alpha (with a seeded transcript), an aider
|
||||
// orchestrator for beta (skipped). Returns the legacy root and the claude dir.
|
||||
func writeLegacyRoot(t *testing.T) (root, claudeDir string) {
|
||||
t.Helper()
|
||||
root = filepath.Join(t.TempDir(), ".agent-orchestrator")
|
||||
claudeDir = filepath.Join(t.TempDir(), "claude")
|
||||
mustMkdir(t, filepath.Join(root, "projects", "alpha", "sessions"))
|
||||
mustMkdir(t, filepath.Join(root, "projects", "beta", "sessions"))
|
||||
|
||||
mustWrite(t, filepath.Join(root, "config.yaml"), `projects:
|
||||
alpha:
|
||||
path: /repos/alpha
|
||||
name: Alpha
|
||||
defaultBranch: develop
|
||||
beta:
|
||||
path: /repos/beta
|
||||
`)
|
||||
|
||||
worktree := filepath.Join(t.TempDir(), "alpha-wt")
|
||||
mustMkdir(t, worktree)
|
||||
mustWrite(t, filepath.Join(root, "projects", "alpha", "sessions", "orchestrator.json"), `{
|
||||
"role": "orchestrator",
|
||||
"agent": "claude-code",
|
||||
"worktree": "`+worktree+`",
|
||||
"claudeSessionUuid": "uuid-alpha",
|
||||
"userPrompt": "go",
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"lifecycle": {"session": {"state": "working", "lastTransitionAt": "2026-01-02T00:00:00Z"}}
|
||||
}`)
|
||||
// Seed the transcript at the legacy source slug so relocation copies it
|
||||
// (resolve symlinks to match planTranscriptCopy's realpath of the worktree).
|
||||
resolvedWt, err := filepath.EvalSymlinks(worktree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcDir := filepath.Join(claudeDir, claudeSlug(resolvedWt))
|
||||
mustMkdir(t, srcDir)
|
||||
mustWrite(t, filepath.Join(srcDir, "uuid-alpha.jsonl"), "transcript")
|
||||
|
||||
mustWrite(t, filepath.Join(root, "projects", "beta", "sessions", "orchestrator.json"), `{
|
||||
"role": "orchestrator",
|
||||
"agent": "aider",
|
||||
"lifecycle": {"session": {"state": "working"}}
|
||||
}`)
|
||||
return root, claudeDir
|
||||
}
|
||||
|
||||
func runOpts(root, claudeDir string) Options {
|
||||
return Options{
|
||||
Root: root,
|
||||
DataDir: filepath.Join(filepath.Dir(root), "data"),
|
||||
ClaudeProjectsDir: claudeDir,
|
||||
Now: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
RepoOriginURL: func(string) string { return "" },
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_EndToEnd(t *testing.T) {
|
||||
root, claudeDir := writeLegacyRoot(t)
|
||||
store := newFakeStore()
|
||||
ctx := context.Background()
|
||||
|
||||
rep, err := Run(ctx, store, runOpts(root, claudeDir))
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if rep.ProjectsImported != 2 {
|
||||
t.Fatalf("projectsImported = %d, want 2", rep.ProjectsImported)
|
||||
}
|
||||
if rep.OrchestratorsImported != 1 {
|
||||
t.Fatalf("orchestratorsImported = %d, want 1 (alpha)", rep.OrchestratorsImported)
|
||||
}
|
||||
if rep.OrchestratorsSkipped != 1 {
|
||||
t.Fatalf("orchestratorsSkipped = %d, want 1 (beta/aider)", rep.OrchestratorsSkipped)
|
||||
}
|
||||
if rep.TranscriptsRelocated != 1 {
|
||||
t.Fatalf("transcriptsRelocated = %d, want 1", rep.TranscriptsRelocated)
|
||||
}
|
||||
// The alpha orchestrator row landed verbatim.
|
||||
o, ok := store.sessions["alpha-orchestrator"]
|
||||
if !ok || o.Kind != domain.KindOrchestrator || o.Metadata.AgentSessionID != "uuid-alpha" {
|
||||
t.Fatalf("alpha orchestrator = %+v ok=%v", o, ok)
|
||||
}
|
||||
// develop branch survives into the config blob.
|
||||
if store.projects["alpha"].Config.DefaultBranch != "develop" {
|
||||
t.Fatalf("alpha config = %+v", store.projects["alpha"].Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_Idempotent(t *testing.T) {
|
||||
root, claudeDir := writeLegacyRoot(t)
|
||||
store := newFakeStore()
|
||||
ctx := context.Background()
|
||||
if _, err := Run(ctx, store, runOpts(root, claudeDir)); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
rep, err := Run(ctx, store, runOpts(root, claudeDir))
|
||||
if err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if rep.ProjectsImported != 0 || rep.ProjectsSkipped != 2 {
|
||||
t.Fatalf("re-run projects: imported=%d skipped=%d, want 0/2", rep.ProjectsImported, rep.ProjectsSkipped)
|
||||
}
|
||||
if rep.OrchestratorsImported != 0 {
|
||||
t.Fatalf("re-run orchestratorsImported = %d, want 0", rep.OrchestratorsImported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_DryRunWritesNothing(t *testing.T) {
|
||||
root, claudeDir := writeLegacyRoot(t)
|
||||
store := newFakeStore()
|
||||
opts := runOpts(root, claudeDir)
|
||||
opts.DryRun = true
|
||||
rep, err := Run(context.Background(), store, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if rep.ProjectsImported != 2 || rep.OrchestratorsImported != 1 {
|
||||
t.Fatalf("dry-run plan = %+v", rep)
|
||||
}
|
||||
if len(store.projects) != 0 || len(store.sessions) != 0 {
|
||||
t.Fatal("dry run must not write to the store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_NoLegacyData(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "empty")
|
||||
rep, err := Run(context.Background(), newFakeStore(), Options{Root: root})
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if rep.ProjectsImported != 0 || len(rep.Notes) == 0 {
|
||||
t.Fatalf("expected empty import with a note, got %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasLegacyData(t *testing.T) {
|
||||
root, _ := writeLegacyRoot(t)
|
||||
if !HasLegacyData(root) {
|
||||
t.Fatal("HasLegacyData = false, want true")
|
||||
}
|
||||
if HasLegacyData(filepath.Join(t.TempDir(), "nope")) {
|
||||
t.Fatal("HasLegacyData = true for missing root")
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(p, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, p, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
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 + `"`
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// Package legacyimport reads the legacy Agent Orchestrator flat-file store
|
||||
// (~/.agent-orchestrator) read-only and ports it into the rewrite's native
|
||||
// SQLite store. It maps the legacy project registry, per-project settings, and
|
||||
// each project's single live orchestrator session, relocating the orchestrator's
|
||||
// Claude transcript so a claude-code orchestrator resumes with context.
|
||||
//
|
||||
// This is the Go port of the legacy-side TypeScript reader (AgentWrapper PR
|
||||
// #2144 / issue #2129); the field mapping is ReverbCode issue #247. The legacy
|
||||
// files are NEVER modified: a declined or failed import loses nothing, and a
|
||||
// re-run skips rows that already exist.
|
||||
package legacyimport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// userHomeDir is indirected so tests can pin the home directory without mutating
|
||||
// process environment.
|
||||
var userHomeDir = os.UserHomeDir
|
||||
|
||||
// DefaultLegacyRootDir returns the canonical legacy state root,
|
||||
// ~/.agent-orchestrator, or "" when the home directory cannot be resolved.
|
||||
func DefaultLegacyRootDir() string {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".agent-orchestrator")
|
||||
}
|
||||
|
||||
// defaultClaudeProjectsDir returns ~/.claude/projects, the directory Claude Code
|
||||
// buckets per-cwd transcripts under. "" when home cannot be resolved.
|
||||
func defaultClaudeProjectsDir() string {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".claude", "projects")
|
||||
}
|
||||
|
||||
// globalConfigPath is the legacy global config file, root/config.yaml.
|
||||
func globalConfigPath(root string) string {
|
||||
return filepath.Join(root, "config.yaml")
|
||||
}
|
||||
|
||||
// preferencesPath / registeredPath are the optional portfolio overlays that
|
||||
// carry UI display names and per-project registration timestamps.
|
||||
func preferencesPath(root string) string {
|
||||
return filepath.Join(root, "portfolio", "preferences.json")
|
||||
}
|
||||
|
||||
func registeredPath(root string) string {
|
||||
return filepath.Join(root, "portfolio", "registered.json")
|
||||
}
|
||||
|
||||
// projectSessionsDir locates a project's sessions directory, accepting both the
|
||||
// current layout (root/projects/{id}/sessions) and the older hashed layout
|
||||
// (root/{hash}-{id}/sessions). It returns "" when neither exists.
|
||||
func projectSessionsDir(root, projectID string) string {
|
||||
primary := filepath.Join(root, "projects", projectID, "sessions")
|
||||
if isDir(primary) {
|
||||
return primary
|
||||
}
|
||||
// Older layout: a top-level "{hash}-{id}" directory. Match by the "-{id}"
|
||||
// suffix; the id itself may contain "-", but the hashed form always prefixes
|
||||
// it, so a suffix match is the faithful locator the legacy reader used.
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
suffix := "-" + projectID
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if name == "projects" || name == "portfolio" {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
cand := filepath.Join(root, name, "sessions")
|
||||
if isDir(cand) {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
package legacyimport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// mapPermission maps a legacy AgentPermissionMode to the rewrite PermissionMode
|
||||
// (issue #247 §3). ok=false means "unset" (no permission to carry); lossy=true
|
||||
// flags a remap that drops a distinction the rewrite cannot represent.
|
||||
func mapPermission(legacy string) (mode domain.PermissionMode, ok, lossy bool) {
|
||||
switch legacy {
|
||||
case "":
|
||||
return "", false, false
|
||||
case "permissionless", "skip":
|
||||
// legacy already collapses skip→permissionless, but a hand-edited config
|
||||
// could carry the raw value, so map it explicitly.
|
||||
return domain.PermissionModeBypassPermissions, true, false
|
||||
case "auto-edit":
|
||||
return domain.PermissionModeAcceptEdits, true, false
|
||||
case "default":
|
||||
return domain.PermissionModeDefault, true, false
|
||||
case "suggest":
|
||||
// The rewrite has no suggest/plan mode (#247 G8).
|
||||
return domain.PermissionModeDefault, true, true
|
||||
default:
|
||||
return domain.PermissionModeDefault, true, true
|
||||
}
|
||||
}
|
||||
|
||||
// mapHarness maps a legacy agent plugin id to a rewrite harness, or ok=false
|
||||
// when the rewrite has no such harness.
|
||||
func mapHarness(agent string) (domain.AgentHarness, bool) {
|
||||
if agent == "" {
|
||||
return "", false
|
||||
}
|
||||
h := domain.AgentHarness(agent)
|
||||
if h.IsKnown() {
|
||||
return h, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func buildAgentConfig(src *legacyAgentConfig, notes *[]string, label string) domain.AgentConfig {
|
||||
var out domain.AgentConfig
|
||||
if src == nil {
|
||||
return out
|
||||
}
|
||||
if m := strings.TrimSpace(src.Model); m != "" {
|
||||
out.Model = m
|
||||
}
|
||||
if mode, ok, lossy := mapPermission(src.Permissions); ok {
|
||||
out.Permissions = mode
|
||||
if lossy {
|
||||
*notes = append(*notes, fmt.Sprintf("%s permission %q mapped lossily to %q", label, src.Permissions, mode))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildRoleOverride(src *legacyRole, notes *[]string, label string) domain.RoleOverride {
|
||||
var out domain.RoleOverride
|
||||
if src == nil {
|
||||
return out
|
||||
}
|
||||
if src.Agent != "" {
|
||||
if h, ok := mapHarness(src.Agent); ok {
|
||||
out.Harness = h
|
||||
} else {
|
||||
*notes = append(*notes, fmt.Sprintf("%s agent %q has no rewrite harness — dropped", label, src.Agent))
|
||||
}
|
||||
}
|
||||
out.AgentConfig = buildAgentConfig(src.AgentConfig, notes, label+" agent")
|
||||
return out
|
||||
}
|
||||
|
||||
// buildProjectConfig maps a legacy project block to the typed rewrite config
|
||||
// blob (issue #247 §3). It appends lossy/dropped notes and returns a config that
|
||||
// may be IsZero (the store persists SQL NULL for that).
|
||||
func buildProjectConfig(pc legacyProjectConfig, notes *[]string) domain.ProjectConfig {
|
||||
var cfg domain.ProjectConfig
|
||||
|
||||
// defaultBranch: omit "main" so the common case keeps config NULL.
|
||||
if b := strings.TrimSpace(pc.DefaultBranch); b != "" && b != domain.DefaultBranchName {
|
||||
cfg.DefaultBranch = b
|
||||
}
|
||||
if pc.SessionPrefix != "" {
|
||||
cfg.SessionPrefix = pc.SessionPrefix
|
||||
}
|
||||
if len(pc.Env) > 0 {
|
||||
cfg.Env = make(map[string]string, len(pc.Env))
|
||||
for k, v := range pc.Env {
|
||||
cfg.Env[k] = v
|
||||
}
|
||||
}
|
||||
if len(pc.Symlinks) > 0 {
|
||||
cfg.Symlinks = append([]string(nil), pc.Symlinks...)
|
||||
}
|
||||
if len(pc.PostCreate) > 0 {
|
||||
cfg.PostCreate = append([]string(nil), pc.PostCreate...)
|
||||
}
|
||||
cfg.AgentConfig = buildAgentConfig(pc.AgentConfig, notes, "agentConfig")
|
||||
cfg.Worker = buildRoleOverride(pc.Worker, notes, "worker")
|
||||
cfg.Orchestrator = buildRoleOverride(pc.Orchestrator, notes, "orchestrator")
|
||||
|
||||
// Surface project-level fields the rewrite has no home for (#247 §4).
|
||||
var dropped []string
|
||||
if pc.Tracker != nil {
|
||||
dropped = append(dropped, "tracker")
|
||||
}
|
||||
if pc.SCM != nil {
|
||||
dropped = append(dropped, "scm")
|
||||
}
|
||||
if pc.AgentRules != nil || pc.AgentRulesFile != nil || pc.OrchestratorRule != nil {
|
||||
dropped = append(dropped, "rules")
|
||||
}
|
||||
if pc.Runtime != nil {
|
||||
dropped = append(dropped, "runtime")
|
||||
}
|
||||
if pc.Workspace != nil {
|
||||
dropped = append(dropped, "workspace")
|
||||
}
|
||||
if pc.Reactions != nil {
|
||||
dropped = append(dropped, "reactions")
|
||||
}
|
||||
if len(dropped) > 0 {
|
||||
*notes = append(*notes, "dropped project fields with no rewrite home: "+strings.Join(dropped, ", "))
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// projectRowDeps are the host effects the project mapper needs: git origin
|
||||
// resolution and the fallback "now" timestamp. Injected so the mapper is pure
|
||||
// and unit-testable.
|
||||
type projectRowDeps struct {
|
||||
repoOriginURL func(path string) string
|
||||
configMtime string // ISO timestamp of config.yaml, or "" if unknown
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// buildProjectRecord builds the rewrite projects row for one legacy project
|
||||
// (issue #247 §1). The rewrite no longer fills server-side fields, so the
|
||||
// importer computes repo_origin_url, registered_at, kind, display_name, config.
|
||||
func buildProjectRecord(id string, pc legacyProjectConfig, prefs preferences, reg registeredManifest, deps projectRowDeps) (domain.ProjectRecord, []string) {
|
||||
var notes []string
|
||||
cfg := buildProjectConfig(pc, ¬es)
|
||||
|
||||
path := normalizePath(pc.Path)
|
||||
|
||||
// display_name: preferences.displayName → config name → "" (rewrite falls
|
||||
// back to id on read, so only persist a real, non-id name).
|
||||
displayName := ""
|
||||
if p, ok := prefs.Projects[id]; ok && p.DisplayName != "" {
|
||||
displayName = p.DisplayName
|
||||
} else if pc.Name != "" && pc.Name != id {
|
||||
displayName = pc.Name
|
||||
}
|
||||
|
||||
// registered_at: registered.json addedAt → config mtime → import time.
|
||||
registeredAt := deps.now
|
||||
if iso := reg.addedAt(id, pc.Path); iso != "" {
|
||||
if t, err := time.Parse(time.RFC3339, iso); err == nil {
|
||||
registeredAt = t
|
||||
}
|
||||
} else if deps.configMtime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, deps.configMtime); err == nil {
|
||||
registeredAt = t
|
||||
}
|
||||
}
|
||||
|
||||
origin := ""
|
||||
if deps.repoOriginURL != nil {
|
||||
origin = deps.repoOriginURL(path)
|
||||
}
|
||||
|
||||
return domain.ProjectRecord{
|
||||
ID: id,
|
||||
Path: path,
|
||||
RepoOriginURL: origin,
|
||||
DisplayName: displayName,
|
||||
RegisteredAt: registeredAt,
|
||||
Kind: domain.ProjectKindSingleRepo,
|
||||
Config: cfg,
|
||||
}, notes
|
||||
}
|
||||
|
||||
// normalizePath ~-expands then absolutises+cleans a legacy project path, matching
|
||||
// the rewrite's normalizePath. A path that cannot be absolutised is returned
|
||||
// cleaned but relative (best effort; the rewrite re-derives worktrees anyway).
|
||||
func normalizePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if p == "~" || strings.HasPrefix(p, "~/") {
|
||||
if home, err := userHomeDir(); err == nil {
|
||||
if p == "~" {
|
||||
p = home
|
||||
} else {
|
||||
p = filepath.Join(home, p[2:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
return abs
|
||||
}
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package legacyimport
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestMapPermission(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want domain.PermissionMode
|
||||
ok bool
|
||||
lossy bool
|
||||
}{
|
||||
{"", "", false, false},
|
||||
{"permissionless", domain.PermissionModeBypassPermissions, true, false},
|
||||
{"skip", domain.PermissionModeBypassPermissions, true, false},
|
||||
{"auto-edit", domain.PermissionModeAcceptEdits, true, false},
|
||||
{"default", domain.PermissionModeDefault, true, false},
|
||||
{"suggest", domain.PermissionModeDefault, true, true},
|
||||
{"weird", domain.PermissionModeDefault, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
mode, ok, lossy := mapPermission(c.in)
|
||||
if mode != c.want || ok != c.ok || lossy != c.lossy {
|
||||
t.Fatalf("mapPermission(%q) = (%q,%v,%v), want (%q,%v,%v)", c.in, mode, ok, lossy, c.want, c.ok, c.lossy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapHarness(t *testing.T) {
|
||||
if h, ok := mapHarness("claude-code"); !ok || h != domain.HarnessClaudeCode {
|
||||
t.Fatalf("claude-code = (%q,%v)", h, ok)
|
||||
}
|
||||
if _, ok := mapHarness("nope"); ok {
|
||||
t.Fatal("unknown harness must map to ok=false")
|
||||
}
|
||||
if _, ok := mapHarness(""); ok {
|
||||
t.Fatal("empty harness must map to ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectConfig_RemapAndOmitMain(t *testing.T) {
|
||||
var notes []string
|
||||
pc := legacyProjectConfig{
|
||||
DefaultBranch: "main", // omitted so config stays minimal
|
||||
SessionPrefix: "px",
|
||||
Env: map[string]string{"K": "V"},
|
||||
AgentConfig: &legacyAgentConfig{Model: "m", Permissions: "suggest"},
|
||||
Worker: &legacyRole{Agent: "codex"},
|
||||
Orchestrator: &legacyRole{Agent: "bogus"}, // no rewrite harness → dropped note
|
||||
Tracker: nonNilNode(),
|
||||
}
|
||||
cfg := buildProjectConfig(pc, ¬es)
|
||||
if cfg.DefaultBranch != "" {
|
||||
t.Fatalf("defaultBranch = %q, want omitted for main", cfg.DefaultBranch)
|
||||
}
|
||||
if cfg.SessionPrefix != "px" || cfg.Env["K"] != "V" {
|
||||
t.Fatalf("config = %+v", cfg)
|
||||
}
|
||||
if cfg.AgentConfig.Permissions != domain.PermissionModeDefault {
|
||||
t.Fatalf("permissions = %q, want default (lossy from suggest)", cfg.AgentConfig.Permissions)
|
||||
}
|
||||
if cfg.Worker.Harness != domain.HarnessCodex {
|
||||
t.Fatalf("worker harness = %q, want codex", cfg.Worker.Harness)
|
||||
}
|
||||
if cfg.Orchestrator.Harness != "" {
|
||||
t.Fatalf("orchestrator harness = %q, want dropped (unknown)", cfg.Orchestrator.Harness)
|
||||
}
|
||||
if len(notes) == 0 {
|
||||
t.Fatal("expected lossy/dropped notes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectConfig_NonMainBranchKept(t *testing.T) {
|
||||
var notes []string
|
||||
cfg := buildProjectConfig(legacyProjectConfig{DefaultBranch: "develop"}, ¬es)
|
||||
if cfg.DefaultBranch != "develop" {
|
||||
t.Fatalf("defaultBranch = %q, want develop", cfg.DefaultBranch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectRecord_DisplayNameAndRegisteredAt(t *testing.T) {
|
||||
now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
prefs := preferences{Projects: map[string]struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
}{"proj": {DisplayName: "Pretty"}}}
|
||||
reg := registeredManifest{Projects: []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
AddedAt string `json:"addedAt"`
|
||||
}{{ID: "proj", AddedAt: "2026-05-05T00:00:00Z"}}}
|
||||
|
||||
deps := projectRowDeps{
|
||||
repoOriginURL: func(string) string { return "git@github.com:o/r.git" },
|
||||
now: now,
|
||||
}
|
||||
rec, _ := buildProjectRecord("proj", legacyProjectConfig{Path: "/repo", Name: "ignored"}, prefs, reg, deps)
|
||||
if rec.DisplayName != "Pretty" {
|
||||
t.Fatalf("displayName = %q, want Pretty (preferences win)", rec.DisplayName)
|
||||
}
|
||||
if rec.RepoOriginURL != "git@github.com:o/r.git" {
|
||||
t.Fatalf("origin = %q", rec.RepoOriginURL)
|
||||
}
|
||||
if rec.RegisteredAt.Format(time.RFC3339) != "2026-05-05T00:00:00Z" {
|
||||
t.Fatalf("registeredAt = %s, want addedAt", rec.RegisteredAt)
|
||||
}
|
||||
if rec.Kind != domain.ProjectKindSingleRepo {
|
||||
t.Fatalf("kind = %s", rec.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectRecord_DisplayNameFallbacks(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
deps := projectRowDeps{now: now}
|
||||
// No preferences, config name == id → empty display name (rewrite falls back to id).
|
||||
rec, _ := buildProjectRecord("proj", legacyProjectConfig{Path: "/r", Name: "proj"}, preferences{}, registeredManifest{}, deps)
|
||||
if rec.DisplayName != "" {
|
||||
t.Fatalf("displayName = %q, want empty", rec.DisplayName)
|
||||
}
|
||||
if !rec.RegisteredAt.Equal(now) {
|
||||
t.Fatalf("registeredAt = %s, want now fallback", rec.RegisteredAt)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ImportSession inserts a session with a caller-supplied id and num, bypassing
|
||||
// CreateSession's per-project num generation so the legacy importer can preserve
|
||||
// a verbatim id (e.g. "{prefix}-orchestrator", num 0). It is idempotent: an id
|
||||
// that already exists is left untouched and inserted=false is returned, so a
|
||||
// re-run of the importer never clobbers a row the daemon may since have evolved.
|
||||
//
|
||||
// Like CreateSession this is a single INSERT under writeMu; the ON CONFLICT
|
||||
// guard makes the existence check and the insert atomic on the writer
|
||||
// connection. It uses raw ExecContext to attach the ON CONFLICT clause the
|
||||
// generated InsertSession query does not carry (the same raw-exec approach
|
||||
// DeleteSession uses to work around sqlc's DELETE handling).
|
||||
func (s *Store) ImportSession(ctx context.Context, rec domain.SessionRecord, num int64) (bool, error) {
|
||||
activity := normalActivity(rec.Activity, rec.CreatedAt)
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
res, err := s.writeDB.ExecContext(ctx, `
|
||||
INSERT INTO sessions (
|
||||
id, project_id, num, issue_id, kind, harness, display_name,
|
||||
activity_state, activity_last_at, first_signal_at, is_terminated,
|
||||
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`,
|
||||
rec.ID,
|
||||
rec.ProjectID,
|
||||
num,
|
||||
rec.IssueID,
|
||||
rec.Kind,
|
||||
rec.Harness,
|
||||
rec.DisplayName,
|
||||
activity.State,
|
||||
activity.LastActivityAt,
|
||||
timeToNullTime(rec.FirstSignalAt),
|
||||
rec.IsTerminated,
|
||||
rec.Metadata.Branch,
|
||||
rec.Metadata.WorkspacePath,
|
||||
rec.Metadata.RuntimeHandleID,
|
||||
rec.Metadata.AgentSessionID,
|
||||
rec.Metadata.Prompt,
|
||||
rec.CreatedAt,
|
||||
rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("import session %s: %w", rec.ID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("import session %s: rows affected: %w", rec.ID, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestImportSessionVerbatimAndIdempotent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
seedProject(t, s, "mer")
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
rec := domain.SessionRecord{
|
||||
ID: "mer-orchestrator",
|
||||
ProjectID: "mer",
|
||||
Kind: domain.KindOrchestrator,
|
||||
Harness: domain.HarnessClaudeCode,
|
||||
Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now},
|
||||
Metadata: domain.SessionMetadata{AgentSessionID: "uuid-1", Prompt: "go"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
inserted, err := s.ImportSession(ctx, rec, 0)
|
||||
if err != nil || !inserted {
|
||||
t.Fatalf("first import: inserted=%v err=%v", inserted, err)
|
||||
}
|
||||
|
||||
got, ok, err := s.GetSession(ctx, "mer-orchestrator")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.Kind != domain.KindOrchestrator || got.Metadata.AgentSessionID != "uuid-1" {
|
||||
t.Fatalf("imported row = %+v", got)
|
||||
}
|
||||
|
||||
// Re-import is a no-op: the existing row is left untouched.
|
||||
inserted, err = s.ImportSession(ctx, rec, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("re-import err: %v", err)
|
||||
}
|
||||
if inserted {
|
||||
t.Fatal("re-import reported inserted=true; want false (idempotent skip)")
|
||||
}
|
||||
|
||||
// num=0 leaves the next store-generated session at num=1 with no collision.
|
||||
w, err := s.CreateSession(ctx, sampleRecord("mer"))
|
||||
if err != nil {
|
||||
t.Fatalf("create worker: %v", err)
|
||||
}
|
||||
if w.ID != "mer-1" {
|
||||
t.Fatalf("worker id = %s, want mer-1 (orchestrator at num 0 must not collide)", w.ID)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue