agent-orchestrator/backend/internal/legacyimport/importer.go

245 lines
7.6 KiB
Go

package legacyimport
import (
"context"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process"
)
// 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 := aoprocess.Command("git", "-C", path, "remote", "get-url", "origin")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}