198 lines
5.7 KiB
Go
198 lines
5.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"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
|
|
|
|
type startOptions struct {
|
|
timeout time.Duration
|
|
logFile string
|
|
json bool
|
|
}
|
|
|
|
func newStartCommand(ctx *commandContext) *cobra.Command {
|
|
opts := startOptions{timeout: defaultStartTimeout}
|
|
cmd := &cobra.Command{
|
|
Use: "start",
|
|
Short: "Start the AO daemon",
|
|
Args: noArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
st, err := ctx.startDaemon(cmd.Context(), opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx.emitCLIInvoked(cmd.Context(), cmd)
|
|
if opts.json {
|
|
return writeJSON(cmd.OutOrStdout(), st)
|
|
}
|
|
if st.State == stateReady {
|
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port)
|
|
return err
|
|
}
|
|
return writeStatus(cmd, st)
|
|
},
|
|
}
|
|
cmd.Flags().DurationVar(&opts.timeout, "timeout", defaultStartTimeout, "How long to wait for daemon readiness")
|
|
cmd.Flags().StringVar(&opts.logFile, "log-file", "", "Daemon log file path")
|
|
cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON")
|
|
return cmd
|
|
}
|
|
|
|
func (c *commandContext) startDaemon(ctx context.Context, opts startOptions) (daemonStatus, error) {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return daemonStatus{}, err
|
|
}
|
|
|
|
st, err := c.inspectDaemon(ctx)
|
|
if err != nil {
|
|
return daemonStatus{}, err
|
|
}
|
|
if st.State == stateReady {
|
|
return st, nil
|
|
}
|
|
if st.State != stateStopped && st.State != stateStale {
|
|
ready, waitErr := c.waitForReady(ctx, opts.timeout)
|
|
if waitErr == nil {
|
|
return ready, nil
|
|
}
|
|
return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr)
|
|
}
|
|
if st.State == stateStale {
|
|
if err := runfile.Remove(cfg.RunFilePath); err != nil {
|
|
return daemonStatus{}, err
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
logPath := opts.logFile
|
|
if logPath == "" {
|
|
logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil {
|
|
return daemonStatus{}, fmt.Errorf("create log dir: %w", err)
|
|
}
|
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return daemonStatus{}, fmt.Errorf("open daemon log: %w", err)
|
|
}
|
|
defer func() { _ = logFile.Close() }()
|
|
|
|
if err := c.deps.StartProcess(processStartConfig{
|
|
Path: exe,
|
|
Args: []string{"daemon"},
|
|
Env: os.Environ(),
|
|
Stdout: logFile,
|
|
Stderr: logFile,
|
|
}); err != nil {
|
|
return daemonStatus{}, fmt.Errorf("start daemon: %w", err)
|
|
}
|
|
|
|
ready, err := c.waitForReady(ctx, opts.timeout)
|
|
if err != nil {
|
|
return daemonStatus{}, fmt.Errorf("%w; see daemon log %s", err, logPath)
|
|
}
|
|
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
|
|
}
|
|
deadline := c.deps.Now().Add(timeout)
|
|
var last daemonStatus
|
|
var lastErr error
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return daemonStatus{}, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
st, err := c.inspectDaemon(ctx)
|
|
if err != nil {
|
|
lastErr = err
|
|
} else {
|
|
last = st
|
|
if st.State == stateReady {
|
|
return st, nil
|
|
}
|
|
}
|
|
|
|
if !c.deps.Now().Before(deadline) {
|
|
if lastErr != nil {
|
|
return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s: %w", timeout, lastErr)
|
|
}
|
|
return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s (last state: %s)", timeout, last.State)
|
|
}
|
|
c.deps.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|