feat(backend): add cobra cli foundation

This commit is contained in:
Dhruv Sharma 2026-05-31 21:10:04 +05:30 committed by harshitsinghbhandari
parent 83d1ea1e88
commit 4671d27307
23 changed files with 1571 additions and 131 deletions

View File

@ -8,18 +8,24 @@ Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/archit
## Backend daemon ## Backend daemon
The Go binary in [`backend/`](backend/) is the HTTP daemon — a loopback-only The Go backend now has a Cobra-based `ao` CLI in [`backend/cmd/ao`](backend/cmd/ao).
sidecar the Electron supervisor will spawn (Phase 1c). Phase 1a landed the The CLI controls the HTTP daemon — a loopback-only sidecar the Electron
skeleton: chi router, middleware stack (recoverer → request-id → logger → supervisor will also use. The daemon skeleton includes the chi router,
real-ip), `/healthz` + `/readyz`, atomic `running.json` PID/port handshake, middleware stack (recoverer → request-id → logger → real-ip), `/healthz` +
graceful shutdown on SIGINT/SIGTERM. `/readyz`, atomic `running.json` PID/port handshake, graceful shutdown on
SIGINT/SIGTERM, SQLite storage, CDC polling, and lifecycle/reaper wiring.
### Run ### Run
```bash ```bash
cd backend cd backend
go run . # binds 127.0.0.1:3001 with all defaults go run ./cmd/ao start # start the daemon and wait for readiness
AO_PORT=3019 go run . # override per invocation go run ./cmd/ao status # inspect PID/port/health/readiness
go run ./cmd/ao stop # gracefully stop the daemon
go run ./cmd/ao daemon # internal daemon entrypoint
go run . # compatibility wrapper; starts the daemon
AO_PORT=3019 go run ./cmd/ao start # override per invocation
``` ```
Health check: Health check:
@ -48,4 +54,3 @@ is intentionally not env-configurable.
cd backend cd backend
gofmt -l . && go build ./... && go vet ./... && go test -race ./... gofmt -l . && go build ./... && go vet ./... && go test -race ./...
``` ```

15
backend/cmd/ao/main.go Normal file
View File

@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/aoagents/agent-orchestrator/backend/internal/cli"
)
func main() {
if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@ -7,6 +7,7 @@ require (
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.51.0 modernc.org/sqlite v1.51.0
) )
@ -14,11 +15,13 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect

View File

@ -1,5 +1,6 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -14,6 +15,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
@ -26,8 +29,13 @@ github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5s
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=

View File

@ -0,0 +1,31 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
func newCompletionCommand() *cobra.Command {
return &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
root := cmd.Root()
out := cmd.OutOrStdout()
switch args[0] {
case "bash":
return root.GenBashCompletion(out)
case "zsh":
return root.GenZshCompletion(out)
case "fish":
return root.GenFishCompletion(out, true)
case "powershell":
return root.GenPowerShellCompletion(out)
default:
return fmt.Errorf("unsupported shell %q", args[0])
}
},
}
}

View File

@ -0,0 +1,115 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
type doctorLevel string
const (
doctorPass doctorLevel = "PASS"
doctorWarn doctorLevel = "WARN"
doctorFail doctorLevel = "FAIL"
)
type doctorCheck struct {
Level doctorLevel
Name string
Message string
}
func newDoctorCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Run local AO health checks",
RunE: func(cmd *cobra.Command, args []string) error {
checks := ctx.runDoctor(cmd.Context())
for _, check := range checks {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
return err
}
}
var failures int
for _, check := range checks {
if check.Level == doctorFail {
failures++
}
}
if failures > 0 {
return fmt.Errorf("doctor found %d failing check(s)", failures)
}
return nil
},
}
}
func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
checks := []doctorCheck{}
cfg, err := config.Load()
if err != nil {
return append(checks, doctorCheck{Level: doctorFail, Name: "config", Message: err.Error()})
}
checks = append(checks, doctorCheck{
Level: doctorPass, Name: "config",
Message: fmt.Sprintf("runFile=%s dataDir=%s port=%d", cfg.RunFilePath, cfg.DataDir, cfg.Port),
})
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
checks = append(checks, doctorCheck{Level: doctorFail, Name: "data-dir", Message: err.Error()})
} else {
checks = append(checks, doctorCheck{Level: doctorPass, Name: "data-dir", Message: cfg.DataDir})
}
store, err := sqlite.Open(cfg.DataDir)
if err != nil {
checks = append(checks, doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()})
} else {
_ = store.Close()
checks = append(checks, doctorCheck{Level: doctorPass, Name: "sqlite", Message: "opened database and applied migrations"})
}
st, err := c.inspectDaemon(ctx)
if err != nil {
checks = append(checks, doctorCheck{Level: doctorFail, Name: "daemon", Message: err.Error()})
} else {
level := doctorPass
switch st.State {
case "stale", "not_ready":
level = doctorWarn
case "unhealthy":
level = doctorFail
}
msg := st.State
if st.PID != 0 {
msg = fmt.Sprintf("%s pid=%d port=%d", msg, st.PID, st.Port)
}
if st.Error != "" {
msg += " (" + st.Error + ")"
}
checks = append(checks, doctorCheck{Level: level, Name: "daemon", Message: msg})
}
checks = append(checks, c.checkTool("git", true))
checks = append(checks, c.checkTool("tmux", false))
checks = append(checks, c.checkTool("zellij", false))
return checks
}
func (c *commandContext) checkTool(name string, required bool) doctorCheck {
path, err := c.deps.LookPath(name)
if err == nil {
return doctorCheck{Level: doctorPass, Name: name, Message: path}
}
if required {
return doctorCheck{Level: doctorFail, Name: name, Message: "not found in PATH"}
}
return doctorCheck{Level: doctorWarn, Name: name, Message: "not found in PATH"}
}

View File

@ -0,0 +1,12 @@
package cli
import (
"encoding/json"
"io"
)
func writeJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}

View File

@ -0,0 +1,30 @@
package cli
import (
"os"
"os/exec"
)
type processStartConfig struct {
Path string
Args []string
Env []string
Stdout *os.File
Stderr *os.File
}
type processHandle struct {
PID int
}
func startProcess(cfg processStartConfig) (processHandle, error) {
cmd := exec.Command(cfg.Path, cfg.Args...)
cmd.Env = cfg.Env
cmd.Stdout = cfg.Stdout
cmd.Stderr = cfg.Stderr
if err := cmd.Start(); err != nil {
return processHandle{}, err
}
go func() { _ = cmd.Wait() }()
return processHandle{PID: cmd.Process.Pid}, nil
}

View File

@ -0,0 +1,24 @@
//go:build !windows
package cli
import (
"errors"
"os"
"syscall"
)
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
func signalTerm(pid int) error {
if pid <= 0 {
return os.ErrProcessDone
}
return syscall.Kill(pid, syscall.SIGTERM)
}

View File

@ -0,0 +1,29 @@
//go:build windows
package cli
import "os"
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
p, err := os.FindProcess(pid)
if err != nil {
return false
}
_ = p.Release()
return true
}
func signalTerm(pid int) error {
if pid <= 0 {
return os.ErrProcessDone
}
p, err := os.FindProcess(pid)
if err != nil {
return err
}
defer p.Release()
return p.Kill()
}

View File

@ -0,0 +1,136 @@
// Package cli implements the user-facing ao command. It stays thin: commands
// discover the local daemon, call its loopback HTTP API, and format output.
package cli
import (
"io"
"net/http"
"os"
"os/exec"
"time"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/daemon"
)
// Execute runs the ao CLI with process stdio.
func Execute() error {
return NewRootCommand(DefaultDeps()).Execute()
}
// Deps holds the small set of side effects the CLI needs. Tests replace these
// functions without reaching into process-global state.
type Deps struct {
In io.Reader
Out io.Writer
Err io.Writer
HTTPClient *http.Client
Executable func() (string, error)
StartProcess func(processStartConfig) (processHandle, error)
SignalTerm func(pid int) error
ProcessAlive func(pid int) bool
LookPath func(file string) (string, error)
Now func() time.Time
Sleep func(time.Duration)
}
// DefaultDeps returns production dependencies.
func DefaultDeps() Deps {
return Deps{
In: os.Stdin,
Out: os.Stdout,
Err: os.Stderr,
HTTPClient: &http.Client{Timeout: 2 * time.Second},
Executable: os.Executable,
StartProcess: startProcess,
SignalTerm: signalTerm,
ProcessAlive: processAlive,
LookPath: exec.LookPath,
Now: time.Now,
Sleep: time.Sleep,
}
}
func (d Deps) withDefaults() Deps {
def := DefaultDeps()
if d.In == nil {
d.In = def.In
}
if d.Out == nil {
d.Out = def.Out
}
if d.Err == nil {
d.Err = def.Err
}
if d.HTTPClient == nil {
d.HTTPClient = def.HTTPClient
}
if d.Executable == nil {
d.Executable = def.Executable
}
if d.StartProcess == nil {
d.StartProcess = def.StartProcess
}
if d.SignalTerm == nil {
d.SignalTerm = def.SignalTerm
}
if d.ProcessAlive == nil {
d.ProcessAlive = def.ProcessAlive
}
if d.LookPath == nil {
d.LookPath = def.LookPath
}
if d.Now == nil {
d.Now = def.Now
}
if d.Sleep == nil {
d.Sleep = def.Sleep
}
return d
}
// NewRootCommand builds a testable root command.
func NewRootCommand(deps Deps) *cobra.Command {
deps = deps.withDefaults()
ctx := &commandContext{deps: deps}
root := &cobra.Command{
Use: "ao",
Short: "Agent Orchestrator",
Long: "Agent Orchestrator manages the local daemon that supervises parallel coding-agent sessions.",
Version: VersionString(),
SilenceUsage: true,
SilenceErrors: true,
}
root.SetIn(deps.In)
root.SetOut(deps.Out)
root.SetErr(deps.Err)
root.CompletionOptions.DisableDefaultCmd = true
root.AddCommand(newDaemonCommand())
root.AddCommand(newStartCommand(ctx))
root.AddCommand(newStopCommand(ctx))
root.AddCommand(newStatusCommand(ctx))
root.AddCommand(newDoctorCommand(ctx))
root.AddCommand(newCompletionCommand())
root.AddCommand(newVersionCommand())
return root
}
type commandContext struct {
deps Deps
}
func newDaemonCommand() *cobra.Command {
return &cobra.Command{
Use: "daemon",
Short: "Run the AO backend daemon",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return daemon.Run()
},
}
}

View File

@ -0,0 +1,159 @@
package cli
import (
"bytes"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)
func TestRootHelpDoesNotShowDaemon(t *testing.T) {
out, _, err := executeCLI(t, Deps{}, "--help")
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "\n daemon") {
t.Fatalf("hidden daemon command leaked into help:\n%s", out)
}
for _, want := range []string{"start", "stop", "status", "doctor", "completion", "version"} {
if !strings.Contains(out, want) {
t.Fatalf("help missing %q:\n%s", want, out)
}
}
}
func TestStatusStoppedJSON(t *testing.T) {
setConfigEnv(t)
out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return false }}, "status", "--json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"state": "stopped"`) {
t.Fatalf("status did not report stopped:\n%s", out)
}
if strings.Contains(out, "startedAt") {
t.Fatalf("stopped JSON should omit startedAt:\n%s", out)
}
}
func TestStartReturnsExistingReadyDaemon(t *testing.T) {
cfg := setConfigEnv(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz":
_, _ = w.Write([]byte(`{"status":"ok"}`))
case "/readyz":
_, _ = w.Write([]byte(`{"status":"ready"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
port := serverPort(t, srv.URL)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
t.Fatal(err)
}
var started bool
out, _, err := executeCLI(t, Deps{
ProcessAlive: func(pid int) bool { return pid == os.Getpid() },
StartProcess: func(processStartConfig) (processHandle, error) {
started = true
return processHandle{}, nil
},
Now: func() time.Time { return time.Unix(110, 0).UTC() },
}, "start", "--json")
if err != nil {
t.Fatal(err)
}
if started {
t.Fatal("start should not spawn when daemon is already ready")
}
if !strings.Contains(out, `"state": "ready"`) {
t.Fatalf("start did not report ready:\n%s", out)
}
}
func TestStopRemovesStaleRunFile(t *testing.T) {
cfg := setConfigEnv(t)
if err := runfile.Write(cfg.runFile, runfile.Info{PID: 999999, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
t.Fatal(err)
}
out, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return false }}, "stop", "--json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"state": "stopped"`) {
t.Fatalf("stop did not report stopped:\n%s", out)
}
info, err := runfile.Read(cfg.runFile)
if err != nil {
t.Fatal(err)
}
if info != nil {
t.Fatalf("stale run-file was not removed: %#v", info)
}
}
type testConfig struct {
runFile string
dataDir string
}
func setConfigEnv(t *testing.T) testConfig {
t.Helper()
dir := t.TempDir()
cfg := testConfig{
runFile: filepath.Join(dir, "running.json"),
dataDir: filepath.Join(dir, "data"),
}
t.Setenv("AO_RUN_FILE", cfg.runFile)
t.Setenv("AO_DATA_DIR", cfg.dataDir)
t.Setenv("AO_PORT", "3001")
t.Setenv("AO_REQUEST_TIMEOUT", "")
t.Setenv("AO_SHUTDOWN_TIMEOUT", "")
return cfg
}
func executeCLI(t *testing.T, deps Deps, args ...string) (string, string, error) {
t.Helper()
var out, errOut bytes.Buffer
deps.Out = &out
deps.Err = &errOut
if deps.Sleep == nil {
deps.Sleep = func(time.Duration) {}
}
cmd := NewRootCommand(deps)
cmd.SetArgs(args)
err := cmd.Execute()
return out.String(), errOut.String(), err
}
func serverPort(t *testing.T, raw string) int {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
_, portRaw, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portRaw)
if err != nil {
t.Fatal(err)
}
return port
}

View File

@ -0,0 +1,138 @@
package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
)
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",
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.startDaemon(cmd.Context(), opts)
if err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
if st.State == "ready" {
_, 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 == "ready" {
return st, nil
}
if st.State != "stopped" && st.State != "stale" {
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)
}
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), 0o755); 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, 0o644)
if err != nil {
return daemonStatus{}, fmt.Errorf("open daemon log: %w", err)
}
defer 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
}
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 == "ready" {
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)
}
}

View File

@ -0,0 +1,187 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)
const probeTimeout = 2 * time.Second
type statusOptions struct {
json bool
}
type daemonStatus struct {
State string `json:"state"`
PID int `json:"pid,omitempty"`
Port int `json:"port,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
Uptime string `json:"uptime,omitempty"`
RunFile string `json:"runFile"`
DataDir string `json:"dataDir"`
Health string `json:"health,omitempty"`
Ready string `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
}
func newStatusCommand(ctx *commandContext) *cobra.Command {
var opts statusOptions
cmd := &cobra.Command{
Use: "status",
Short: "Show AO daemon status",
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.inspectDaemon(cmd.Context())
if err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
return writeStatus(cmd, st)
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "Output status as JSON")
return cmd
}
func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error) {
cfg, err := config.Load()
if err != nil {
return daemonStatus{}, err
}
st := daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}
info, err := runfile.Read(cfg.RunFilePath)
if err != nil {
return daemonStatus{}, err
}
if info == nil {
return st, nil
}
st.PID = info.PID
st.Port = info.Port
startedAt := info.StartedAt
st.StartedAt = &startedAt
st.Uptime = formatUptime(c.deps.Now().Sub(info.StartedAt))
if !c.deps.ProcessAlive(info.PID) {
st.State = "stale"
st.Error = "run-file points to a dead process"
return st, nil
}
health, err := c.readProbe(ctx, info.Port, "healthz")
if err != nil {
st.State = "unhealthy"
st.Error = err.Error()
return st, nil
}
st.Health = health
ready, err := c.readProbe(ctx, info.Port, "readyz")
if err != nil {
st.State = "not_ready"
st.Error = err.Error()
return st, nil
}
st.Ready = ready
if ready == "ready" {
st.State = "ready"
return st, nil
}
st.State = "not_ready"
return st, nil
}
func (c *commandContext) readProbe(ctx context.Context, port int, path string) (string, error) {
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), nil)
if err != nil {
return "", err
}
resp, err := c.deps.HTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("%s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("%s: HTTP %d", path, resp.StatusCode)
}
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", fmt.Errorf("%s: decode response: %w", path, err)
}
if body.Status == "" {
return "", fmt.Errorf("%s: missing status", path)
}
return body.Status, nil
}
func writeStatus(cmd *cobra.Command, st daemonStatus) error {
out := cmd.OutOrStdout()
if _, err := fmt.Fprintf(out, "AO daemon: %s\n", st.State); err != nil {
return err
}
if st.PID != 0 {
if _, err := fmt.Fprintf(out, " pid: %d\n", st.PID); err != nil {
return err
}
}
if st.Port != 0 {
if _, err := fmt.Fprintf(out, " port: %d\n", st.Port); err != nil {
return err
}
}
if st.StartedAt != nil && !st.StartedAt.IsZero() {
if _, err := fmt.Fprintf(out, " started: %s\n", st.StartedAt.Format(time.RFC3339)); err != nil {
return err
}
}
if st.Uptime != "" {
if _, err := fmt.Fprintf(out, " uptime: %s\n", st.Uptime); err != nil {
return err
}
}
if _, err := fmt.Fprintf(out, " run file: %s\n", st.RunFile); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " data dir: %s\n", st.DataDir); err != nil {
return err
}
if st.Health != "" {
if _, err := fmt.Fprintf(out, " healthz: %s\n", st.Health); err != nil {
return err
}
}
if st.Ready != "" {
if _, err := fmt.Fprintf(out, " readyz: %s\n", st.Ready); err != nil {
return err
}
}
if st.Error != "" {
if _, err := fmt.Fprintf(out, " error: %s\n", st.Error); err != nil {
return err
}
}
return nil
}
func formatUptime(d time.Duration) string {
if d < 0 {
d = 0
}
return d.Round(time.Second).String()
}

View File

@ -0,0 +1,106 @@
package cli
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)
const defaultStopTimeout = 10 * time.Second
type stopOptions struct {
timeout time.Duration
json bool
}
func newStopCommand(ctx *commandContext) *cobra.Command {
opts := stopOptions{timeout: defaultStopTimeout}
cmd := &cobra.Command{
Use: "stop",
Short: "Stop the AO daemon",
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.stopDaemon(cmd.Context(), opts)
if err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
if st.State == "stopped" {
_, err = fmt.Fprintln(cmd.OutOrStdout(), "AO daemon stopped")
return err
}
return writeStatus(cmd, st)
},
}
cmd.Flags().DurationVar(&opts.timeout, "timeout", defaultStopTimeout, "How long to wait for daemon shutdown")
cmd.Flags().BoolVar(&opts.json, "json", false, "Output stop result as JSON")
return cmd
}
func (c *commandContext) stopDaemon(ctx context.Context, opts stopOptions) (daemonStatus, error) {
cfg, err := config.Load()
if err != nil {
return daemonStatus{}, err
}
st, err := c.inspectDaemon(ctx)
if err != nil {
return daemonStatus{}, err
}
switch st.State {
case "stopped":
return st, nil
case "stale":
if err := runfile.Remove(cfg.RunFilePath); err != nil {
return daemonStatus{}, err
}
return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil
}
if err := c.deps.SignalTerm(st.PID); err != nil {
if c.deps.ProcessAlive(st.PID) {
return daemonStatus{}, fmt.Errorf("signal daemon pid %d: %w", st.PID, err)
}
_ = runfile.Remove(cfg.RunFilePath)
return daemonStatus{State: "stopped", RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}, nil
}
return c.waitForStopped(ctx, st.PID, cfg.RunFilePath, cfg.DataDir, opts.timeout)
}
func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePath, dataDir string, timeout time.Duration) (daemonStatus, error) {
if timeout <= 0 {
timeout = defaultStopTimeout
}
deadline := c.deps.Now().Add(timeout)
for {
select {
case <-ctx.Done():
return daemonStatus{}, ctx.Err()
default:
}
info, err := runfile.Read(runFilePath)
if err != nil {
return daemonStatus{}, err
}
alive := c.deps.ProcessAlive(pid)
if info == nil {
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
}
if !alive {
if err := runfile.Remove(runFilePath); err != nil {
return daemonStatus{}, err
}
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
}
if !c.deps.Now().Before(deadline) {
return daemonStatus{}, fmt.Errorf("daemon pid %d did not stop within %s", pid, timeout)
}
c.deps.Sleep(100 * time.Millisecond)
}
}

View File

@ -0,0 +1,37 @@
package cli
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// Build metadata. Release tooling can override these with -ldflags.
var (
Version = "dev"
Commit = ""
Date = ""
)
func VersionString() string {
parts := []string{Version}
if Commit != "" {
parts = append(parts, "commit "+Commit)
}
if Date != "" {
parts = append(parts, "built "+Date)
}
return strings.Join(parts, " ")
}
func newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version information",
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), VersionString())
return err
},
}
}

View File

@ -1,4 +1,4 @@
package main package daemon
import ( import (
"context" "context"

View File

@ -0,0 +1,126 @@
// Package daemon owns the Agent Orchestrator backend process: config loading,
// loopback HTTP serving, durable storage, CDC fan-out, lifecycle wiring, and
// graceful shutdown.
package daemon
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
)
// Run starts the daemon and blocks until it exits. SIGINT/SIGTERM drive
// graceful shutdown through the HTTP server and background workers.
func Run() error {
cfg, err := config.Load()
if err != nil {
return err
}
log := NewLogger()
// Fail fast if a live daemon already owns the handshake file. A run-file
// left by a crashed predecessor (dead PID) is treated as stale and
// overwritten when the new server starts.
if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil {
return fmt.Errorf("inspect run-file: %w", err)
} else if live != nil {
return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port)
}
// Open the durable store and bring up the CDC substrate: the DB triggers
// capture changes into change_log, the poller tails it, and the broadcaster
// fans events out to the SSE transport. The LCM/Session Manager and the HTTP
// API routes that drive and read this store are owned by the daemon lane and
// are wired there once their collaborators (Notifier, AgentMessenger, and the
// runtime/agent/workspace plugins) have production implementations; here we
// stand up the persistence + change-delivery foundation they build on.
store, err := sqlite.Open(cfg.DataDir)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer store.Close()
// signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the
// graceful shutdown inside Server.Run and stops the background goroutines.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cdcPipe, err := startCDC(ctx, store, log)
if err != nil {
return err
}
// Terminal streaming: the tmux runtime supplies the PTY-attach command and
// liveness; the CDC broadcaster feeds the session-state channel. The manager
// is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow
// through the CDC change_log — only session-state events do.
runtimeAdapter := tmux.New(tmux.Options{})
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
defer termMgr.Close()
srv, err := httpd.New(cfg, log, termMgr)
if err != nil {
return err
}
// Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE
// timer). This makes the write path live end-to-end: LCM write -> store -> DB
// trigger -> change_log -> poller -> broadcaster.
lcStack, err := startLifecycle(ctx, store, log)
if err != nil {
return err
}
// Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree)
// are real on main; ports.Agent has no production adapter yet, so a loud
// stub returns a sentinel command that makes any Spawn fail at the runtime
// layer rather than start a broken session quietly. Notifier and
// AgentMessenger remain stubbed alongside the LCM until their multiplexers
// land. No HTTP routes wire to this yet — the daemon lane (#10) owns API
// surfacing — so we hold the SM in a local until it does.
sStack, err := startSession(ctx, cfg, lcStack, log)
if err != nil {
// startSession is the first start* call after this point that can
// realistically fail while the cdc poller and the reaper are already
// running. Mirror the bottom-of-run shutdown sequence so both have
// drained before the deferred store.Close() fires. Defers would hit
// the LIFO trap (see comment after srv.Run), hence explicit.
stop()
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return err
}
_ = sStack
runErr := srv.Run(ctx)
// Shut the background goroutines down in order: cancel the context FIRST so
// their loops exit, then wait for them to drain. Doing this explicitly (not
// via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel
// runs before the cancel — which would hang any non-signal exit path.
stop()
lcStack.Stop()
if err := cdcPipe.Stop(); err != nil {
log.Error("cdc pipeline shutdown", "err", err)
}
return runErr
}
// NewLogger returns the daemon's slog logger. It writes to stderr so supervisors
// can capture it separately from any structured stdout protocol added later.
func NewLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
}

View File

@ -1,4 +1,4 @@
package main package daemon
import ( import (
"context" "context"

View File

@ -1,4 +1,4 @@
package main package daemon
import ( import (
"context" "context"

View File

@ -1,133 +1,18 @@
// Command backend is the Agent Orchestrator HTTP daemon: a loopback-only // Command backend is a compatibility wrapper for the Agent Orchestrator daemon.
// sidecar spawned and supervised by the Electron main process. Phase 1a brings // The user-facing CLI lives at cmd/ao; keep this wrapper so existing `go run .`
// up the server skeleton — config, 127.0.0.1 bind, middleware stack, health // development workflows continue to start the daemon while scripts migrate.
// probes, the running.json handshake, and graceful shutdown.
package main package main
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/signal"
"syscall"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" "github.com/aoagents/agent-orchestrator/backend/internal/daemon"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
) )
func main() { func main() {
if err := run(); err != nil { if err := daemon.Run(); err != nil {
fmt.Fprintln(os.Stderr, "ao backend daemon: "+err.Error()) fmt.Fprintln(os.Stderr, "ao backend daemon: "+err.Error())
os.Exit(1) os.Exit(1)
} }
} }
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
log := newLogger()
// Fail fast if a live daemon already owns the handshake file. A run-file
// left by a crashed predecessor (dead PID) is treated as stale and
// overwritten when the new server starts.
if live, err := runfile.CheckStale(cfg.RunFilePath); err != nil {
return fmt.Errorf("inspect run-file: %w", err)
} else if live != nil {
return fmt.Errorf("daemon already running (pid %d, port %d); refusing to start", live.PID, live.Port)
}
// Open the durable store and bring up the CDC substrate: the DB triggers
// capture changes into change_log, the poller tails it, and the broadcaster
// fans events out to the SSE transport. The LCM/Session Manager and the HTTP
// API routes that drive and read this store are owned by the daemon lane and
// are wired there once their collaborators (Notifier, AgentMessenger, and the
// runtime/agent/workspace plugins) have production implementations; here we
// stand up the persistence + change-delivery foundation they build on.
store, err := sqlite.Open(cfg.DataDir)
if err != nil {
return fmt.Errorf("open store: %w", err)
}
defer store.Close()
// signal.NotifyContext cancels ctx on SIGINT/SIGTERM, which drives the
// graceful shutdown inside Server.Run and stops the background goroutines.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cdcPipe, err := startCDC(ctx, store, log)
if err != nil {
return err
}
// Terminal streaming: the tmux runtime supplies the PTY-attach command and
// liveness; the CDC broadcaster feeds the session-state channel. The manager
// is handed to httpd, which mounts it at /mux. Raw PTY bytes never flow
// through the CDC change_log — only session-state events do.
runtimeAdapter := tmux.New(tmux.Options{})
termMgr := terminal.NewManager(runtimeAdapter, cdcPipe.Broadcaster, log)
defer termMgr.Close()
srv, err := httpd.New(cfg, log, termMgr)
if err != nil {
return err
}
// Bring up the Lifecycle Manager (sole store writer) and the reaper (OBSERVE
// timer). This makes the write path live end-to-end: LCM write -> store -> DB
// trigger -> change_log -> poller -> broadcaster.
lcStack, err := startLifecycle(ctx, store, log)
if err != nil {
return err
}
// Bring up the Session Manager. Runtime (tmux) and Workspace (gitworktree)
// are real on main; ports.Agent has no production adapter yet, so a loud
// stub returns a sentinel command that makes any Spawn fail at the runtime
// layer rather than start a broken session quietly. Notifier and
// AgentMessenger remain stubbed alongside the LCM until their multiplexers
// land. No HTTP routes wire to this yet — the daemon lane (#10) owns API
// surfacing — so we hold the SM in a local until it does.
sStack, err := startSession(ctx, cfg, lcStack, log)
if err != nil {
// startSession is the first start* call after this point that can
// realistically fail while the cdc poller and the reaper are already
// running. Mirror the bottom-of-run shutdown sequence so both have
// drained before the deferred store.Close() fires. Defers would hit
// the LIFO trap (see comment after srv.Run), hence explicit.
stop()
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return err
}
_ = sStack
runErr := srv.Run(ctx)
// Shut the background goroutines down in order: cancel the context FIRST so
// their loops exit, then wait for them to drain. Doing this explicitly (not
// via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel
// runs before the cancel — which would hang any non-signal exit path.
stop()
lcStack.Stop()
if err := cdcPipe.Stop(); err != nil {
log.Error("cdc pipeline shutdown", "err", err)
}
return runErr
}
// newLogger returns the daemon's slog logger. It writes to stderr so the
// Electron supervisor can capture it separately from any structured stdout
// protocol added later.
func newLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
}

View File

@ -15,6 +15,7 @@ fakes) on the `feat/lcm-sm-contracts` integration branch.
|-----|----------------| |-----|----------------|
| [architecture.md](architecture.md) | How the lane works: the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants. Read this first. | | [architecture.md](architecture.md) | How the lane works: the OBSERVE→DECIDE→ACT loop, the canonical state model, the package layout, every component, and the load-bearing invariants. Read this first. |
| [status.md](status.md) | What's done (PR by PR), what's left, the integration to-dos, the open cross-lane contract questions, and how to build/test. | | [status.md](status.md) | What's done (PR by PR), what's left, the integration to-dos, the open cross-lane contract questions, and how to build/test. |
| [cli/README.md](cli/README.md) | CLI foundation decisions: Cobra, reference projects, old CLI inventory, and the first command surface. |
## The one-paragraph mental model ## The one-paragraph mental model

393
docs/cli/README.md Normal file
View File

@ -0,0 +1,393 @@
# AO CLI Foundation
This page is the running decision log for the Agent Orchestrator CLI. Keep new
CLI decisions here as the command surface grows.
## Current State
This branch implements the daemon-control foundation. AO now has a Go/Cobra
`ao` binary that can start, inspect, diagnose, and stop the local backend daemon
end to end.
What works now:
- `ao start` starts the daemon in the background and waits for `/readyz`.
- `ao status` and `ao status --json` report stopped, stale, unhealthy,
not-ready, or ready daemon state.
- `ao stop` gracefully stops the daemon using the PID in `running.json`.
- `ao daemon` is the hidden internal daemon entrypoint used by `ao start`.
- `ao doctor` checks config, data dir, SQLite migrations, daemon state, and
local tool availability for `git`, `tmux`, and `zellij`.
- `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and
`powershell`.
- `ao version` and `ao --version` print build metadata.
- `go run .` still works as a compatibility wrapper around `internal/daemon.Run`.
Manual smoke test:
```bash
cd backend
go build -o /tmp/ao ./cmd/ao
tmp=$(mktemp -d)
export AO_RUN_FILE="$tmp/running.json"
export AO_DATA_DIR="$tmp/data"
export AO_PORT=3037
/tmp/ao status --json
/tmp/ao doctor
/tmp/ao start
/tmp/ao status --json
/tmp/ao stop
/tmp/ao status --json
```
What is intentionally not implemented yet:
- `ao project ...`
- `ao spawn`
- `ao session ...`
- `ao send`
- `ao events ...`
Next steps:
1. Add `/api/v1/projects` on the daemon over a small project service.
2. Implement `ao project list/add/show/remove`.
3. Wire production Session Manager dependencies: project-backed repo resolver,
tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
4. Add `/api/v1/sessions`, then implement `ao spawn`, `ao session ...`, and
`ao send`.
5. Add `/events` SSE and durable event-list reads, then implement
`ao events tail/list`.
## Decision
AO will use a single Go CLI binary built with
[Cobra](https://github.com/spf13/cobra).
The CLI is a thin client for the Go daemon. It should not call SQLite, runtime
adapters, agent adapters, workspace adapters, or SCM integrations directly. It
should start, discover, inspect, and command the daemon through the loopback API
and the existing `running.json` handshake.
Initial rules:
- The binary name is `ao`.
- `ao daemon` is the hidden/internal entrypoint for the long-running daemon.
- User-facing commands call the daemon over loopback after reading
`running.json`.
- Commands that mutate core AO state go through HTTP API routes, not direct
stores.
- Commands support predictable text output first and `--json` where automation
is likely.
- Do not introduce Viper in the foundation. Start with explicit flags and a
small config/client layer, then add config loading once the shape is real.
## References
These projects inform the direction, but AO should keep its own command surface
smaller at first.
| Project | CLI stack | What to take |
|---|---|---|
| [Gastown](https://github.com/gastownhall/gastown) | Go + Cobra, with Charmbracelet packages for richer terminal UI | Simple `cmd/<binary>/main.go` delegating to internal command construction. Useful confirmation that Cobra is the right default for this size of Go CLI. |
| [GitHub CLI](https://github.com/cli/cli) | Go + Cobra | Command factories, explicit IO streams, JSON output, and testable command construction. |
| [Docker CLI](https://github.com/docker/cli) | Go + Cobra | Daemon/client split, command groups, signal handling, and plugin-aware CLI layout. |
| [kubectl](https://github.com/kubernetes/kubectl) | Go + Cobra | Large command tree patterns and IO abstractions. It is a useful ceiling, not a shape to copy now. |
| [Tailscale CLI](https://github.com/tailscale/tailscale) | Go + ffcli | Useful daemon-backed product model: a CLI talks to a local daemon. Do not copy the framework choice. |
The old AO TypeScript CLI is a product/workflow reference only. We should not
port its implementation because it mixes CLI, storage, runtime, and project
logic in-process. The rewrite needs the CLI to sit outside the core daemon.
## Current Legacy CLI Inventory
Inventory source: installed `ao` binary at version `0.9.2`, plus
`/Users/dhruvsharma/Development/agent-orchestrator/packages/cli/src/program.ts`
and `packages/cli/src/commands/*.ts`.
Count:
- 25 public top-level commands, excluding Commander-generated `help`.
- 26 visible top-level commands if generated `help` is counted.
- 64 explicit public command nodes when nested subcommands are counted.
- 1 hidden internal command: `completion __complete`.
- No aliases are registered in the old Commander source.
Top-level commands:
| Command | Legacy purpose | Foundation decision |
|---|---|---|
| `start` | Start orchestrator agent and dashboard | Keep, but redefine as daemon start. |
| `stop` | Stop orchestrator agent and dashboard | Keep, daemon stop. |
| `status` | Show all sessions and project/session health | Keep, daemon and session status. |
| `spawn` | Spawn a single agent session | Keep after session API exists. |
| `batch-spawn` | Spawn many sessions | Defer. |
| `session` | Manage sessions | Keep a smaller subset after session API exists. |
| `send` | Send a message to a session | Keep after messaging API exists. |
| `acknowledge` | Agent self-reporting hook | Defer or replace with internal API. |
| `report` | Agent workflow transition hook | Defer or replace with internal API. |
| `review-check` | Trigger agents from review comments | Defer. |
| `review` | Manage AO-local reviewer runs | Defer. |
| `dashboard` | Start web dashboard | Defer to Electron/frontend lane. |
| `open` | Open terminal/dashboard | Defer. |
| `verify` | Verify issue after staging check | Defer. |
| `doctor` | Run install/env/runtime checks | Keep. |
| `update` | Upgrade AO | Defer to packaging/release lane. |
| `setup` | Configure integrations | Defer. |
| `plugin` | Plugin marketplace/install flow | Defer. |
| `notify` | Notification test commands | Defer. |
| `project` | Manage registered projects | Keep after project API exists. |
| `migrate-storage` | Legacy storage migration | Drop for rewrite unless a real migration appears. |
| `completion` | Generate shell completions | Keep. |
| `events` | Query activity event log | Keep a small `tail`/`list` surface after event API exists. |
| `config` | Read/write old global config | Defer. Avoid until config shape is stable. |
| `config-help` | Print old config schema | Drop. |
Nested legacy commands:
| Parent | Subcommands |
|---|---|
| `session` | `ls`, `attach`, `kill`, `cleanup`, `claim-pr`, `restore`, `remap` |
| `review` | `run`, `execute`, `send`, `list` |
| `setup` | `dashboard`, `desktop`, `webhook`, `slack`, `discord`, `composio`, `composio-slack`, `composio-discord`, `composio-discord-bot`, `composio-mail`, `openclaw` |
| `plugin` | `list`, `search`, `create`, `install`, `update`, `uninstall` |
| `project` | `ls`, `add`, `rm`, `set-default` |
| `events` | `list`, `search`, `stats` |
| `config` | `set`, `get` |
| `notify` | `test` |
| `completion` | `zsh`, hidden `__complete` |
## Initial Command Surface
The first CLI should make AO installable, startable, inspectable, and stoppable
before trying to recreate the old product surface.
### Foundation Commands
These are the first commands to implement.
| Command | Purpose | Notes |
|---|---|---|
| `ao start` | Start the daemon, wait for `/readyz`, and print PID/port. | Reads the same config env as the daemon. Should be idempotent when an existing healthy daemon is already running. |
| `ao stop` | Stop the running daemon. | Reads `running.json`, sends graceful termination, waits for run-file removal, and reports stale/dead daemon state clearly. |
| `ao status` | Show daemon status and, once APIs exist, project/session summary. | First version can show run-file, process liveness, `/healthz`, `/readyz`, uptime, and port. Add `--json`; add `--watch` once useful. |
| `ao daemon` | Hidden internal daemon entrypoint. | This replaces the current direct `go run .` daemon entrypoint once `main.go` is extracted into `internal/daemon`. |
| `ao doctor` | Diagnose the local environment. | Start with daemon/run-file/port checks, required binaries, config dir/data dir permissions, and runtime availability. |
| `ao completion` | Generate shell completions. | Cobra can support `bash`, `zsh`, `fish`, and `powershell`. |
| `ao version` | Print CLI and build metadata. | Implement as both `ao version` and Cobra's `--version` flag. |
This gives a useful first release even before project/session mutation routes are
complete.
### First Core Application Commands
These are the next commands once daemon HTTP routes expose the needed managers.
| Command | Purpose | Depends on |
|---|---|---|
| `ao project list` | List registered projects. | Project API. Alias `ls` is acceptable for old muscle memory. |
| `ao project add <path-or-url>` | Register a project. | Project API and project identity rules. |
| `ao project show <id>` | Inspect project config and health. | Project API. |
| `ao project remove <id>` | Archive/remove a project. | Project API. Alias `rm` is acceptable. |
| `ao spawn [issue]` | Spawn one coding-agent session. | Session Manager HTTP route, tracker lookup, workspace/runtime/agent adapters. |
| `ao session list` | List sessions across projects or one project. | Session API. Alias `ls` is acceptable. |
| `ao session show <session>` | Show one session with lifecycle, PR, CI, runtime, and paths. | Session API. |
| `ao session attach <session>` | Attach to the runtime terminal. | Runtime API or direct terminal attach contract exposed by daemon. |
| `ao session kill <session>` | Kill a session and clean up safely. | Session Manager `Kill`. |
| `ao session restore <session>` | Restore a terminated/crashed session. | Session Manager `Restore`. |
| `ao send <session> [message...]` | Send instructions to a running session. | AgentMessenger route. |
| `ao events tail` | Follow daemon activity events. | SSE/CDC API. |
| `ao events list` | List recent activity events. | Event read API. |
This is the smallest surface that covers the core product loop:
1. Register a repo.
2. Start AO.
3. Spawn work.
4. Inspect work.
5. Intervene in work.
6. Stop AO.
## Explicit Deferrals
Do not include these in the CLI foundation:
- `batch-spawn`: valuable, but it multiplies error handling before single-spawn
semantics are stable.
- `dashboard` and `open`: frontend/Electron should own the primary dashboard
launch path first.
- `review`, `review-check`, and `verify`: useful workflow automation, but not
required to run core AO.
- `setup`, `plugin`, and `notify`: integration/plugin surface should come after
the daemon API and config model settle.
- `update`: belongs with distribution and release packaging.
- `config` and `config-help`: wait for a stable Go config model. Avoid copying
the old TypeScript global config behavior.
- `migrate-storage`: old storage migration is not part of the rewrite unless a
concrete migration requirement appears.
- `acknowledge` and `report`: these are agent self-reporting hooks. Prefer a
daemon/internal protocol before exposing them as durable user CLI commands.
## Implementation Plan
1. Add Cobra to `backend/go.mod`.
2. Move current daemon startup from `backend/main.go` into
`backend/internal/daemon.Run(ctx, opts)`.
3. Add `backend/cmd/ao/main.go` as the only user binary entrypoint.
4. Add `backend/internal/cli` for command construction, IO streams, process
launching, run-file discovery, loopback HTTP client, and output formatting.
5. Implement `ao daemon` first so the current daemon behavior is preserved.
6. Implement `ao start`, `ao stop`, and `ao status` around `running.json` and
`/healthz`/`/readyz`.
7. Add `ao doctor`, `ao completion`, and `ao version`.
8. Add command tests using Cobra command construction with fake IO, fake process
runner, and fake daemon client. Keep daemon integration tests in the daemon
packages.
Suggested package layout:
```text
backend/
cmd/
ao/
main.go
internal/
cli/
root.go
start.go
stop.go
status.go
doctor.go
completion.go
version.go
client.go
output.go
process.go
daemon/
daemon.go
```
Acceptance criteria for the foundation:
- `go run ./cmd/ao daemon` behaves like today's `go run .`.
- `go run ./cmd/ao start` starts the daemon and waits until `/readyz` returns
ready.
- `go run ./cmd/ao status --json` works when the daemon is running, stopped, and
stale.
- `go run ./cmd/ao stop` gracefully stops the daemon and removes `running.json`.
- `go test ./...`, `go vet ./...`, and `go test -race ./...` pass.
## Implementation Readiness
This section records what the CLI can connect to in the current codebase and
what still needs to be built. Inventory date: 2026-05-31 on `main` at
`0672dbb`.
### Implemented Foundation
The daemon-control foundation now exists in `backend/cmd/ao` and
`backend/internal/cli`.
Implemented commands:
- `ao daemon` hidden/internal daemon entrypoint.
- `ao start` starts the daemon, waits for `/readyz`, and supports `--json`,
`--timeout`, and `--log-file`.
- `ao stop` stops the daemon from `running.json`, removes stale run-files, and
supports `--json` and `--timeout`.
- `ao status` reports stopped/stale/unhealthy/not-ready/ready states and
supports `--json`.
- `ao doctor` checks config, data dir, SQLite open/migrations, daemon state, and
local tool availability for `git`, `tmux`, and `zellij`.
- `ao completion` generates `bash`, `zsh`, `fish`, and `powershell`
completions.
- `ao version` prints build metadata.
The old `backend/main.go` remains as a compatibility wrapper around
`internal/daemon.Run`, so `go run .` still starts the daemon while scripts move
to `go run ./cmd/ao ...`.
### Already Implemented and Directly Usable by the CLI
These pieces are available now and are enough to build the daemon-management
part of the CLI.
| Area | Existing code | CLI use |
|---|---|---|
| Daemon config | `backend/internal/config` loads `AO_PORT`, `AO_REQUEST_TIMEOUT`, `AO_SHUTDOWN_TIMEOUT`, `AO_RUN_FILE`, and `AO_DATA_DIR`. Host is fixed to `127.0.0.1`. | `ao start`, `ao daemon`, `ao status`, and `ao doctor` can share the same config resolution. |
| HTTP server lifecycle | `backend/internal/httpd.Server` binds loopback, writes `running.json`, serves until context cancellation, then removes `running.json`. | `ao daemon` can preserve today's daemon behavior after extraction into `internal/daemon`. |
| Health probes | `GET /healthz` and `GET /readyz`. | `ao start` can wait for readiness; `ao status` and `ao doctor` can check daemon health. |
| Run-file handshake | `backend/internal/runfile` reads, writes, removes, and stale-checks `running.json`. | `ao status` can discover PID/port; `ao stop` can find the process; `ao start` can detect an already-running daemon. |
| Durable store | `backend/internal/storage/sqlite` opens SQLite, runs goose migrations, uses WAL, stores projects/sessions/PR/check/comment rows, and reads `change_log`. | Not directly called by user CLI commands, but confirms the daemon has a durable backend once APIs expose it. |
| CDC substrate | `backend/internal/cdc` poller and broadcaster exist; daemon starts the poller with `startCDC`. | Future `ao events tail` can build on this once an SSE/API transport exists. |
| Lifecycle manager | `backend/internal/lifecycle` is implemented and currently wired in daemon startup. | Session/status APIs can use it; CLI must wait for HTTP routes rather than calling it directly. |
| Reaper timer | `backend/internal/observe/reaper` exists and is wired. | Runtime liveness will be available once runtime registry wiring exists. |
### Implemented Internally but Not Reachable by CLI Yet
These are real backend components, but the CLI cannot responsibly use them until
they are wired into the daemon and exposed through HTTP.
| Area | Existing code | Missing before CLI can use it |
|---|---|---|
| Project persistence | `sqlite.Store` has `UpsertProject`, `GetProject`, `ListProjects`, and `ArchiveProject`. | Project domain/service layer, project ID/path/origin validation, and `/api/v1/projects` routes. |
| Session Manager | `backend/internal/session.Manager` implements `Spawn`, `Kill`, `Restore`, `List`, `Get`, `Send`, and `Cleanup`. | Production daemon wiring with real runtime, agent, workspace, messenger, and HTTP routes. |
| Runtime adapters | tmux and zellij adapters implement `ports.Runtime` and also have attach/send/output helpers. | Runtime registry wiring in daemon, attach/send abstractions in ports/API, and selection config. |
| Workspace adapter | git worktree adapter implements create/destroy/restore/list with safety checks. | Repo resolver backed by registered projects and daemon wiring into Session Manager. |
| GitHub issue tracker | `backend/internal/adapters/tracker/github` implements read-only issue `Get`, `List`, and `Preflight`. | Tracker registry/config, spawn prompt hydration, and project tracker metadata. |
| PR facts storage | SQLite PR/check/comment writes and CDC triggers exist. | SCM/PR observer that fetches GitHub PR/CI/review facts and calls `LCM.ApplyPRObservation`. |
| Session read model | `SessionManager.List/Get` derive display status from canonical lifecycle + PR facts. | HTTP response DTOs and API routes for CLI/frontend reads. |
### Still Missing
These are the main gaps before the full initial command set is real.
| Gap | Blocks |
|---|---|
| Cobra dependency and CLI packages. | All CLI commands. |
| Daemon extraction from `backend/main.go` into `internal/daemon`. | `ao daemon`, `ao start`, tests around daemon startup. |
| CLI process runner and PID signal helpers. | `ao start`, `ao stop`. |
| Loopback HTTP client package with run-file discovery. | `ao status`, later all daemon-backed commands. |
| Shutdown mechanism choice: PID signal now, optional `POST /api/v1/daemon/shutdown` later. | `ao stop` polish and cross-platform behavior. |
| HTTP API route surface under `/api/v1`. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. |
| SSE route for live CDC events plus durable catch-up reads. | `ao events tail`, frontend live updates. |
| Agent adapters for supported harnesses (`codex`, `claude-code`, etc.). | `ao spawn`, `ao session restore`. |
| AgentMessenger implementation over tmux/zellij. | `ao send`, LCM auto-nudge reactions. |
| Runtime registry wired with tmux/zellij. | Reaper liveness, `session attach`, spawn/kill/restore runtime work. |
| Notifier implementation/multiplexer. | Human notifications and LCM escalation side effects. |
| Activity hooks or agent self-report protocol. | Accurate working/idle/needs-input status beyond runtime/PR facts. |
| Project/tracker config model. | `project add/show`, tracker-backed `spawn`, `doctor` config checks. |
| OpenAPI/DTO/error contract. | Stable CLI/frontend API clients and tests. |
### Command Readiness Matrix
| Command | Can implement now? | Existing support | Remaining work |
|---|---:|---|---|
| `ao daemon` | Implemented | Current daemon startup is extracted to `internal/daemon.Run`. | None for foundation. |
| `ao start` | Implemented | Config, run-file stale check, HTTP readiness probes. | Later: package-manager/service integration if needed. |
| `ao stop` | Implemented | Run-file discovery gives PID/port; server exits cleanly on SIGINT/SIGTERM. | Optional later shutdown HTTP route. |
| `ao status` | Partially implemented | Run-file, process liveness via PID, `/healthz`, `/readyz`. | Rich project/session summary waits for `/api/v1/projects` and `/api/v1/sessions`. |
| `ao doctor` | Partially implemented | Config resolution, run-file, storage open, runtime binary checks. | Deeper adapter preflights need daemon wiring/config. |
| `ao completion` | Implemented | Cobra generators. | None for foundation. |
| `ao version` | Implemented | Build metadata can be injected with `-ldflags`. | Release tooling needs to set metadata. |
| `ao project list/add/show/remove` | Not yet | SQLite project CRUD exists. | Project service and HTTP routes. CLI must not write SQLite directly. |
| `ao spawn` | Not yet | Session Manager exists; runtime/workspace/tracker pieces partly exist. | Agent adapters, registry/config wiring, project lookup, tracker hydration, HTTP route. |
| `ao session list/show` | Not yet | Store and Session Manager read model exist. | HTTP routes and response DTOs. |
| `ao session attach` | Not yet | tmux/zellij have attach command helpers. | Runtime attach port/API and terminal-launch policy. |
| `ao session kill/restore` | Not yet | Session Manager implements both. | Production wiring and HTTP routes. |
| `ao send` | Not yet | Session Manager has `Send`; tmux/zellij have send helpers. | AgentMessenger implementation, port/API wiring, busy/idle delivery policy. |
| `ao events tail/list` | Not yet | Durable `change_log`, CDC poller, in-process broadcaster. | SSE route and durable event-list route. |
### Recommended Build Order
1. Build CLI foundation around the daemon only: `daemon`, `start`, `stop`,
`status`, `doctor`, `completion`, `version`.
2. Add `/api/v1/projects` over a small project service, then implement
`project list/add/show/remove`.
3. Wire production Session Manager dependencies: project-backed repo resolver,
tmux/zellij runtime registry, first agent adapter, and AgentMessenger.
4. Add `/api/v1/sessions` and implement `spawn`, `session list/show/kill/restore`,
and `send`.
5. Add `/events` SSE plus event-list reads, then implement `events tail/list`.