agent-orchestrator/backend/internal/cli/import.go

175 lines
5.5 KiB
Go

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
}