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
55 changes: 55 additions & 0 deletions .github/workflows/cli-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CLI E2E

on:
push:
branches: [main]
pull_request:
paths:
- "backend/**"
- "test/cli/**"
- ".github/workflows/cli-e2e.yml"

permissions:
contents: read

jobs:
# Primary tier: the cross-platform Go E2E suite (build tag `e2e`) runs the real
# `ao` binary against isolated state on every OS GitHub hosts. These runners
# are the "VMs" — the only place that exercises the OS-specific process-detach
# paths (unix Setsid vs Windows CREATE_NEW_PROCESS_GROUP) and os.UserConfigDir
# resolution. The suite builds its own binary and self-allocates a free port.
native:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.25"
cache: false

- name: CLI E2E (native)
run: go test -tags e2e -v ./internal/cli/...

# Secondary hardening tier: prove that a freshly installed binary works on a
# clean machine with no Go toolchain and no developer state. The Dockerfile
# installs `ao` on PATH in a slim image and runs test/cli/install-check.sh.
# --init gives a real PID-1 reaper so the daemon the check starts is reaped
# after `stop` instead of lingering as a zombie.
container:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Build fresh-install image
run: docker build -f test/cli/Dockerfile -t ao-cli-smoke .

- name: Fresh-install check (container)
run: docker run --rm --init ao-cli-smoke
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@ Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/archit

## Backend daemon

The Go binary in [`backend/`](backend/) is the HTTP daemon — a loopback-only
sidecar the Electron supervisor will spawn (Phase 1c). Phase 1a landed the
skeleton: chi router, middleware stack (recoverer → request-id → logger →
real-ip), `/healthz` + `/readyz`, atomic `running.json` PID/port handshake,
graceful shutdown on SIGINT/SIGTERM.
The Go backend now has a Cobra-based `ao` CLI in [`backend/cmd/ao`](backend/cmd/ao).
The CLI controls the HTTP daemon — a loopback-only sidecar the Electron
supervisor will also use. The daemon skeleton includes the chi router,
middleware stack (recoverer → request-id → logger → real-ip), `/healthz` +
`/readyz`, atomic `running.json` PID/port handshake, graceful shutdown on
SIGINT/SIGTERM, SQLite storage, CDC polling, and lifecycle/reaper wiring.

### Run

```bash
cd backend
go run . # binds 127.0.0.1:3001 with all defaults
AO_PORT=3019 go run . # override per invocation
go run ./cmd/ao start # start the daemon and wait for readiness
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:
Expand Down Expand Up @@ -48,4 +54,3 @@ is intentionally not env-configurable.
cd backend
gofmt -l . && go build ./... && go vet ./... && go test -race ./...
```

15 changes: 15 additions & 0 deletions backend/cmd/ao/main.go
Original file line number Diff line number Diff line change
@@ -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(cli.ExitCode(err))
}
}
5 changes: 4 additions & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,24 @@ require (
github.com/creack/pty v1.1.24
github.com/go-chi/chi/v5 v5.1.0
github.com/pressly/goose/v3 v3.27.1
github.com/spf13/cobra v1.10.1
golang.org/x/sys v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.51.0
)

require (
github.com/dustin/go-humanize v1.0.1 // 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/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
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/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand All @@ -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/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/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/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
Expand All @@ -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
Expand Down
37 changes: 37 additions & 0 deletions backend/internal/cli/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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: func(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return usageError{err}
}
return nil
},
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
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])
}
},
}
}
155 changes: 155 additions & 0 deletions backend/internal/cli/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cli

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"

"github.com/spf13/cobra"

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

type doctorLevel string

const (
doctorPass doctorLevel = "PASS"
doctorWarn doctorLevel = "WARN"
doctorFail doctorLevel = "FAIL"
)

type doctorCheck struct {
Level doctorLevel `json:"level"`
Name string `json:"name"`
Message string `json:"message"`
}

type doctorReport struct {
OK bool `json:"ok"`
Failures int `json:"failures"`
Checks []doctorCheck `json:"checks"`
}

func newDoctorCommand(ctx *commandContext) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "doctor",
Short: "Run local AO health checks",
RunE: func(cmd *cobra.Command, args []string) error {
checks := ctx.runDoctor(cmd.Context())
failures := 0
for _, check := range checks {
if check.Level == doctorFail {
failures++
}
}

if asJSON {
if err := writeJSON(cmd.OutOrStdout(), doctorReport{
OK: failures == 0, Failures: failures, Checks: checks,
}); err != nil {
return err
}
} else {
for _, check := range checks {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s: %s\n", check.Level, check.Name, check.Message); err != nil {
return err
}
}
}

if failures > 0 {
return fmt.Errorf("doctor found %d failing check(s)", failures)
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Output health checks as JSON")
return cmd
}

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

checks = append(checks, checkStore(cfg.DataDir))

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
}

// checkStore inspects the SQLite store WITHOUT opening or migrating it. The
// daemon is the sole writer and migrator of the database (architecture.md §7);
// the CLI must never run migrations or open a second writer against a database
// a live daemon may already own. Migrations are validated by the daemon at
// startup and surfaced through /readyz, so doctor only confirms whether the
// database file exists yet.
func checkStore(dataDir string) doctorCheck {
dbPath := filepath.Join(dataDir, "ao.db")
info, err := os.Stat(dbPath)
switch {
case err == nil:
return doctorCheck{
Level: doctorPass, Name: "sqlite",
Message: fmt.Sprintf("%s (%d bytes); migrations are applied by the daemon at startup", dbPath, info.Size()),
}
case errors.Is(err, fs.ErrNotExist):
return doctorCheck{
Level: doctorWarn, Name: "sqlite",
Message: "database not created yet; run `ao start` to initialize and migrate it",
}
default:
return doctorCheck{Level: doctorFail, Name: "sqlite", Message: err.Error()}
}
}

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"}
}
Loading
Loading