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 // 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 } // Options configure one import run. type Options struct { // Root is the legacy state root to read (default ~/.agent-orchestrator). Root string // DryRun parses + plans every row but writes nothing. DryRun bool // 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 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 into store. It never modifies // legacy files. It is idempotent: existing rows are skipped. A per-project // parse or write failure is recorded as a note and does not abort the whole // run, except a store write error, which is returned. func Run(ctx context.Context, store Store, opts Options) (Report, error) { root := opts.Root if root == "" { 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: 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 } } 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 appendPrefixed(dst []string, id string, notes []string) []string { for _, n := range notes { dst = append(dst, id+": "+n) } return dst } // quote wraps s in double quotes for note messages, rendering an empty string as // "?" so a missing value is still legible. func quote(s string) string { if s == "" { return `"?"` } return `"` + s + `"` } // defaultRepoOriginURL resolves a repo's git origin URL, "" when the repo is // absent or has no origin. Matches the rewrite's resolveGitOriginURL. func defaultRepoOriginURL(path string) string { 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)) }