Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions backend/internal/cli/import.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package cli

import (
"bufio"
"context"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"

"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)

type importOptions struct {
from string
dryRun bool
yes bool
json bool
}

func newImportCommand(ctx *commandContext) *cobra.Command {
var opts importOptions
cmd := &cobra.Command{
Use: "import",
Short: "Import projects and orchestrator sessions from a legacy AO install",
Long: "Import reads the legacy Agent Orchestrator flat-file store " +
"(~/.agent-orchestrator) read-only and ports its projects, per-project " +
"settings, and each project's live orchestrator session into the rewrite " +
"database. Legacy files are never modified, and a re-run skips rows that " +
"already exist, so it is safe to run more than once.\n\n" +
"The daemon must be stopped: it is the sole writer of the database.",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return ctx.runImport(cmd, opts)
},
}
cmd.Flags().StringVar(&opts.from, "from", "", "Legacy AO root to read (default ~/.agent-orchestrator)")
cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Parse and report the planned import without writing")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip the confirmation prompt (for non-interactive use)")
cmd.Flags().BoolVar(&opts.json, "json", false, "Output the import report as JSON")
return cmd
}

func (c *commandContext) runImport(cmd *cobra.Command, opts importOptions) error {
cfg, err := config.Load()
if err != nil {
return err
}

// The daemon is the sole writer; refuse to open the store underneath a live
// one. A stale run-file (dead PID) is treated as safe.
if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil {
return fmt.Errorf("inspect run-file: %w", err)
} else if live != nil {
return usageError{fmt.Errorf("the AO daemon is running (pid %d); stop it first with `ao stop` before importing", live.PID)}
}

root := opts.from
if root == "" {
root = legacyimport.DefaultLegacyRootDir()
}
if !legacyimport.HasLegacyData(root) {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "No legacy AO projects found at %s. Nothing to import.\n", root)
return err
}

if !opts.dryRun && !opts.yes {
ok, err := confirm(c.deps.In, cmd.OutOrStdout(),
fmt.Sprintf("Import projects and orchestrator sessions from %s?", root), true)
if err != nil {
return err
}
if !ok {
_, err := fmt.Fprintln(cmd.OutOrStdout(), "Import cancelled.")
return err
}
}

rep, err := c.executeImport(cmd.Context(), cfg, legacyimport.Options{
Root: root,
DataDir: cfg.DataDir,
DryRun: opts.dryRun,
})
if err != nil {
return err
}

if opts.json {
return writeJSON(cmd.OutOrStdout(), rep)
}
return writeImportSummary(cmd.OutOrStdout(), rep)
}

// executeImport opens the rewrite store, runs the import, and closes the store.
// It is the one CLI path that opens the database directly: the import is a
// one-time bootstrap that must run with the daemon stopped (guarded by the
// caller), so it cannot go through the daemon's loopback API.
func (c *commandContext) executeImport(ctx context.Context, cfg config.Config, opts legacyimport.Options) (legacyimport.Report, error) {
store, err := sqlite.Open(cfg.DataDir)
if err != nil {
return legacyimport.Report{}, fmt.Errorf("open store: %w", err)
}
defer func() { _ = store.Close() }()
return legacyimport.Run(ctx, store, opts)
}

func writeImportSummary(w io.Writer, rep legacyimport.Report) error {
var b strings.Builder
if rep.DryRun {
b.WriteString("Dry run — no changes written.\n")
}
fmt.Fprintf(&b, "Projects: %d imported, %d already present\n", rep.ProjectsImported, rep.ProjectsSkipped)
fmt.Fprintf(&b, "Orchestrators: %d imported, %d skipped, %d absent\n", rep.OrchestratorsImported, rep.OrchestratorsSkipped, rep.OrchestratorsAbsent)
fmt.Fprintf(&b, "Transcripts: %d relocated\n", rep.TranscriptsRelocated)
if len(rep.Notes) > 0 {
b.WriteString("\nNotes:\n")
for _, n := range rep.Notes {
fmt.Fprintf(&b, " - %s\n", n)
}
}
_, err := io.WriteString(w, b.String())
return err
}

// confirm prompts for a yes/no answer. When stdin is not an interactive
// terminal it returns the default without prompting, so headless invocations
// behave deterministically.
func confirm(in io.Reader, out io.Writer, prompt string, defaultYes bool) (bool, error) {
suffix := " [Y/n] "
if !defaultYes {
suffix = " [y/N] "
}
if !stdinIsInteractive(in) {
return defaultYes, nil
}
if _, err := io.WriteString(out, prompt+suffix); err != nil {
return false, err
}
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && line == "" {
// EOF with no input: fall back to the default.
return defaultYes, nil
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "":
return defaultYes, nil
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return false, nil
}
}

// stdinIsInteractive reports whether in is an interactive terminal. It only
// treats the real os.Stdin as potentially interactive; a piped reader or test
// buffer is non-interactive.
func stdinIsInteractive(in io.Reader) bool {
f, ok := in.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
70 changes: 70 additions & 0 deletions backend/internal/cli/import_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package cli

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)

func writeLegacyProject(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), ".agent-orchestrator")
if err := os.MkdirAll(filepath.Join(root, "projects", "alpha", "sessions"), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "config.yaml"),
[]byte("projects:\n alpha:\n path: /repos/alpha\n"), 0o600); err != nil {
t.Fatal(err)
}
return root
}

func TestImportCommand_NoLegacyData(t *testing.T) {
setConfigEnv(t)
empty := filepath.Join(t.TempDir(), "nope")
out, _, err := executeCLI(t, Deps{}, "import", "--from", empty, "--yes")
if err != nil {
t.Fatalf("import: %v", err)
}
if !strings.Contains(out, "Nothing to import") {
t.Fatalf("out = %q, want 'Nothing to import'", out)
}
}

func TestImportCommand_ImportsProjectJSON(t *testing.T) {
setConfigEnv(t)
root := writeLegacyProject(t)

out, _, err := executeCLI(t, Deps{}, "import", "--from", root, "--yes", "--json")
if err != nil {
t.Fatalf("import: %v", err)
}
var rep legacyimport.Report
if err := json.Unmarshal([]byte(out), &rep); err != nil {
t.Fatalf("parse report %q: %v", out, err)
}
if rep.ProjectsImported != 1 {
t.Fatalf("projectsImported = %d, want 1", rep.ProjectsImported)
}
}

func TestImportCommand_RefusesWhenDaemonRunning(t *testing.T) {
cfg := setConfigEnv(t)
root := writeLegacyProject(t)

// A run-file owned by this (alive) process makes the daemon look live.
if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: 3001, StartedAt: time.Now()}); err != nil {
t.Fatalf("write run-file: %v", err)
}

_, _, err := executeCLI(t, Deps{}, "import", "--from", root, "--yes")
if err == nil || !strings.Contains(err.Error(), "daemon is running") {
t.Fatalf("err = %v, want refusal because daemon is running", err)
}
}
1 change: 1 addition & 0 deletions backend/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
root.AddCommand(newSpawnCommand(ctx))
root.AddCommand(newSendCommand(ctx))
root.AddCommand(newHooksCommand(ctx))
root.AddCommand(newImportCommand(ctx))
root.AddCommand(newProjectCommand(ctx))
root.AddCommand(newSessionCommand(ctx))
root.AddCommand(newOrchestratorCommand(ctx))
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/cli/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"github.com/spf13/cobra"

"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)

const defaultStartTimeout = 10 * time.Second
Expand Down Expand Up @@ -74,6 +76,12 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da
}
}

// First-boot opt-in: before launching the daemon (so the import runs with the
// store unlocked and the daemon as sole writer afterwards), offer to import a
// legacy AO install. Declining or any import failure is non-fatal — the
// daemon still starts and the user can run `ao import` later.
c.maybeFirstBootImport(ctx, cfg)

exe, err := c.deps.Executable()
if err != nil {
return daemonStatus{}, fmt.Errorf("resolve executable: %w", err)
Expand Down Expand Up @@ -109,6 +117,49 @@ func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (da
return ready, nil
}

// maybeFirstBootImport offers to import a legacy AO install the first time the
// daemon is started against an empty rewrite database. It is best-effort: every
// failure path degrades to "start the daemon fresh" so a broken or absent legacy
// store can never block startup. A non-interactive boot (Electron/headless)
// never auto-imports; it prints a one-line hint to run `ao import` explicitly.
func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Config) {
root := legacyimport.DefaultLegacyRootDir()
if !legacyimport.HasLegacyData(root) {
return
}

store, err := sqlite.Open(cfg.DataDir)
if err != nil {
return // the daemon will surface a real store error on its own open
}
defer func() { _ = store.Close() }()

projects, err := store.ListProjects(ctx)
if err != nil || len(projects) > 0 {
// Already imported (or populated) — don't offer again.
return
}

out := c.deps.Out
if !stdinIsInteractive(c.deps.In) {
_, _ = fmt.Fprintf(out, "Found existing AO projects at %s. Run `ao import` to bring them in.\n", root)
return
}

ok, err := confirm(c.deps.In, out, "Found existing AO projects and sessions. Import them now?", true)
if err != nil || !ok {
_, _ = fmt.Fprintln(out, "Continuing fresh. Run `ao import` later to bring in your existing data.")
return
}

rep, err := legacyimport.Run(ctx, store, legacyimport.Options{Root: root, DataDir: cfg.DataDir})
if err != nil {
_, _ = fmt.Fprintf(out, "Import failed: %v\nContinuing fresh; legacy data is untouched. Retry with `ao import`.\n", err)
return
}
_ = writeImportSummary(out, rep)
}

func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration) (daemonStatus, error) {
if timeout <= 0 {
timeout = defaultStartTimeout
Expand Down
Loading
Loading