245 lines
7.6 KiB
Go
245 lines
7.6 KiB
Go
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))
|
|
}
|