diff --git a/backend/internal/service/importer/importer.go b/backend/internal/service/importer/importer.go new file mode 100644 index 000000000..57427d845 --- /dev/null +++ b/backend/internal/service/importer/importer.go @@ -0,0 +1,67 @@ +// Package importer is the controller-facing service for the legacy-AO import. +// It wraps the internal/legacyimport engine with a detection probe (is a legacy +// install present?) and a trigger that runs the import through the live daemon's +// store, so the daemon stays the sole writer. Whether to PROMPT for the import +// is the desktop app's job (the app-state.json migration marker), so this probe +// reports only physical availability, not "already imported". +package importer + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" +) + +// Store is the storage slice the import runs through; *sqlite.Store satisfies it. +type Store interface { + legacyimport.Store +} + +// Status reports whether a legacy AO install is physically present to import. +type Status struct { + Available bool `json:"available"` + LegacyRoot string `json:"legacyRoot"` +} + +// Service is the controller-facing import contract. +type Service interface { + Status(ctx context.Context) (Status, error) + Run(ctx context.Context) (legacyimport.Report, error) +} + +// Deps bundles the import service's dependencies. +type Deps struct { + // Store is the rewrite's durable store (the daemon's shared *sqlite.Store). + Store Store + // Root overrides the legacy AO root to read. Empty -> the default. + Root string +} + +// Manager implements Service over the daemon's store. +type Manager struct { + store Store + root string +} + +var _ Service = (*Manager)(nil) + +// New constructs the import service. An empty Root falls back to the default. +func New(deps Deps) *Manager { + root := deps.Root + if root == "" { + root = legacyimport.DefaultLegacyRootDir() + } + return &Manager{store: deps.Store, root: root} +} + +// Status reports availability only: legacy data present at the root. It never +// errors on a missing legacy store; that is simply "not available". +func (m *Manager) Status(_ context.Context) (Status, error) { + return Status{Available: legacyimport.HasLegacyData(m.root), LegacyRoot: m.root}, nil +} + +// Run executes the import through the daemon's store. Idempotent: the engine +// skips rows that already exist. Legacy files are never modified. +func (m *Manager) Run(ctx context.Context) (legacyimport.Report, error) { + return legacyimport.Run(ctx, m.store, legacyimport.Options{Root: m.root}) +} diff --git a/backend/internal/service/importer/importer_test.go b/backend/internal/service/importer/importer_test.go new file mode 100644 index 000000000..8c4fa33c7 --- /dev/null +++ b/backend/internal/service/importer/importer_test.go @@ -0,0 +1,76 @@ +package importer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +type fakeStore struct{ projects map[string]domain.ProjectRecord } + +func newFakeStore() *fakeStore { return &fakeStore{projects: map[string]domain.ProjectRecord{}} } +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 writeLegacyRoot(t *testing.T) string { + t.Helper() + root := filepath.Join(t.TempDir(), ".agent-orchestrator") + if err := os.MkdirAll(filepath.Join(root, "projects"), 0o750); err != nil { + t.Fatal(err) + } + cfg := "projects:\n alpha:\n path: /repos/alpha\n name: Alpha\n" + if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + return root +} + +func TestStatus_NoLegacyData(t *testing.T) { + svc := New(Deps{Store: newFakeStore(), Root: filepath.Join(t.TempDir(), "nope")}) + st, err := svc.Status(context.Background()) + if err != nil || st.Available { + t.Fatalf("want unavailable; got %+v err=%v", st, err) + } +} + +func TestStatus_LegacyPresentStaysAvailableAfterImport(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + st, err := svc.Status(context.Background()) + if err != nil || !st.Available || st.LegacyRoot != root { + t.Fatalf("want available at %q; got %+v err=%v", root, st, err) + } + if _, err := svc.Run(context.Background()); err != nil { + t.Fatalf("run: %v", err) + } + // Availability is physical (legacy data still on disk), so it stays true; the + // app marker is what stops the prompt after a completed import. + st, _ = svc.Status(context.Background()) + if !st.Available { + t.Fatal("availability must remain true after import (marker governs prompting)") + } +} + +func TestRun_ImportsProjects(t *testing.T) { + root := writeLegacyRoot(t) + svc := New(Deps{Store: newFakeStore(), Root: root}) + rep, err := svc.Run(context.Background()) + if err != nil || rep.ProjectsImported != 1 { + t.Fatalf("projectsImported=%d err=%v", rep.ProjectsImported, err) + } +} + +func TestNew_DefaultsRoot(t *testing.T) { + if New(Deps{Store: newFakeStore()}).root == "" { + t.Fatal("empty Root should fall back to the default legacy root") + } +}