feat(importer): availability probe + projects-only run

Create service/importer.Manager with Status (physical availability check only,
no DB heuristic) and Run (delegates to legacyimport.Run). The app-state.json
marker governs whether to prompt; this service only answers whether legacy data
is physically present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-26 20:45:46 +05:30
parent 7295956520
commit 666d5ba028
No known key found for this signature in database
2 changed files with 143 additions and 0 deletions

View File

@ -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})
}

View File

@ -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")
}
}