fix(cli): harden daemon control surface and stop CLI from writing the store
Addresses review findings on PR #53 (on top of the rebase onto main). - doctor: stop opening/migrating SQLite. The daemon is the sole store writer/migrator (architecture.md §7); the CLI must not run migrations or open a second writer against a DB a live daemon owns. doctor now reports database-file presence and gains --json. - stop: only remove running.json when it still belongs to the PID we stopped, so a concurrent `ao start` that wrote a new run-file is not clobbered into looking stopped. - httpd: gate POST /shutdown to loopback callers with no Origin header, closing the CSRF / DNS-rebinding vector against an unauthenticated, state-changing endpoint. - start: detach the spawned daemon into its own session/process group so a Ctrl-C while `ao start` waits for readiness doesn't also kill it. - cli: exit 2 for usage errors (bad flag / arg count) vs 1 for runtime failures. - daemon: unexport newLogger (only used in-package). - tests: /shutdown guard (cross-origin + rebinding) and stop run-file ownership guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d8ffcd17a
commit
2d00e4675d
|
|
@ -10,6 +10,6 @@ import (
|
|||
func main() {
|
||||
if err := cli.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
os.Exit(cli.ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import (
|
|||
|
||||
func newCompletionCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Args: cobra.ExactArgs(1),
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
type doctorLevel string
|
||||
|
|
@ -20,34 +22,53 @@ const (
|
|||
)
|
||||
|
||||
type doctorCheck struct {
|
||||
Level doctorLevel
|
||||
Name string
|
||||
Message string
|
||||
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 {
|
||||
return &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())
|
||||
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
|
||||
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 {
|
||||
|
|
@ -68,13 +89,7 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
|||
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"})
|
||||
}
|
||||
checks = append(checks, checkStore(cfg.DataDir))
|
||||
|
||||
st, err := c.inspectDaemon(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -103,6 +118,31 @@ func (c *commandContext) runDoctor(ctx context.Context) []doctorCheck {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ func startProcess(cfg processStartConfig) (processHandle, error) {
|
|||
cmd.Env = cfg.Env
|
||||
cmd.Stdout = cfg.Stdout
|
||||
cmd.Stderr = cfg.Stderr
|
||||
// Detach the daemon into its own session/process group so a Ctrl-C in the
|
||||
// terminal where `ao start` is waiting for readiness doesn't also SIGINT the
|
||||
// freshly spawned daemon (it would otherwise share the launcher's group).
|
||||
cmd.SysProcAttr = detachSysProcAttr()
|
||||
if err := cmd.Start(); err != nil {
|
||||
return processHandle{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,10 @@ func processAlive(pid int) bool {
|
|||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
// detachSysProcAttr puts the daemon in a new session (Setsid) so it is no
|
||||
// longer in the launcher's foreground process group and won't receive the
|
||||
// terminal's SIGINT/SIGHUP.
|
||||
func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package cli
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
|
@ -27,3 +28,9 @@ func processAlive(pid int) bool {
|
|||
}
|
||||
return status == uint32(windows.WAIT_TIMEOUT)
|
||||
}
|
||||
|
||||
// detachSysProcAttr starts the daemon in a new process group so it does not
|
||||
// receive the console's CTRL_C/CTRL_BREAK while `ao start` waits for readiness.
|
||||
func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -19,6 +20,27 @@ func Execute() error {
|
|||
return NewRootCommand(DefaultDeps()).Execute()
|
||||
}
|
||||
|
||||
// usageError marks a command-line misuse (bad flag, wrong arg count). It lets
|
||||
// the process entrypoint return exit code 2 for usage errors versus 1 for
|
||||
// runtime failures, matching the convention CLIs are scripted against.
|
||||
type usageError struct{ err error }
|
||||
|
||||
func (e usageError) Error() string { return e.err.Error() }
|
||||
func (e usageError) Unwrap() error { return e.err }
|
||||
|
||||
// ExitCode maps a CLI error to a process exit code: 2 for usage errors, 1 for
|
||||
// any other failure, 0 for success.
|
||||
func ExitCode(err error) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var ue usageError
|
||||
if errors.As(err, &ue) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Deps holds the small set of side effects the CLI needs. Tests replace these
|
||||
// functions without reaching into process-global state.
|
||||
type Deps struct {
|
||||
|
|
@ -103,6 +125,11 @@ func NewRootCommand(deps Deps) *cobra.Command {
|
|||
root.SetOut(deps.Out)
|
||||
root.SetErr(deps.Err)
|
||||
root.CompletionOptions.DisableDefaultCmd = true
|
||||
// Tag flag-parse failures as usage errors so the entrypoint can exit 2 for
|
||||
// misuse versus 1 for runtime failures. Subcommands inherit this func.
|
||||
root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
|
||||
return usageError{err}
|
||||
})
|
||||
|
||||
root.AddCommand(newDaemonCommand())
|
||||
root.AddCommand(newStartCommand(ctx))
|
||||
|
|
|
|||
|
|
@ -115,8 +115,14 @@ func (c *commandContext) waitForStopped(ctx context.Context, pid int, runFilePat
|
|||
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
|
||||
}
|
||||
if !alive {
|
||||
if err := runfile.Remove(runFilePath); err != nil {
|
||||
return daemonStatus{}, err
|
||||
// Only remove the run-file if it still belongs to the process we
|
||||
// stopped. A concurrent `ao start` may have already written a new
|
||||
// run-file for a different daemon; removing that would corrupt its
|
||||
// handshake and make a live daemon look stopped.
|
||||
if info.PID == pid {
|
||||
if err := runfile.Remove(runFilePath); err != nil {
|
||||
return daemonStatus{}, err
|
||||
}
|
||||
}
|
||||
return daemonStatus{State: "stopped", RunFile: runFilePath, DataDir: dataDir}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
|
||||
)
|
||||
|
||||
// TestWaitForStoppedKeepsRunFileFromConcurrentStart guards against deleting a
|
||||
// fresh daemon's handshake: if a concurrent `ao start` replaces running.json
|
||||
// with a new live PID while we are polling the PID we stopped, waitForStopped
|
||||
// must report stopped but leave the new run-file intact.
|
||||
func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runFile := filepath.Join(dir, "running.json")
|
||||
|
||||
const stoppedPID, newPID = 1111, 2222
|
||||
// running.json now belongs to a different, live daemon.
|
||||
if err := runfile.Write(runFile, runfile.Info{PID: newPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := &commandContext{deps: Deps{
|
||||
ProcessAlive: func(pid int) bool { return pid == newPID }, // stoppedPID is dead
|
||||
Now: func() time.Time { return time.Unix(200, 0).UTC() },
|
||||
Sleep: func(time.Duration) {},
|
||||
}.withDefaults()}
|
||||
|
||||
st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State != "stopped" {
|
||||
t.Fatalf("state = %q, want stopped", st.State)
|
||||
}
|
||||
|
||||
info, err := runfile.Read(runFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info == nil {
|
||||
t.Fatal("new daemon's run-file was deleted by stop of a different PID")
|
||||
}
|
||||
if info.PID != newPID {
|
||||
t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForStoppedRemovesOwnRunFile confirms the normal path still cleans up:
|
||||
// when the dead PID owns the run-file, it is removed.
|
||||
func TestWaitForStoppedRemovesOwnRunFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runFile := filepath.Join(dir, "running.json")
|
||||
|
||||
const stoppedPID = 1111
|
||||
if err := runfile.Write(runFile, runfile.Info{PID: stoppedPID, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := &commandContext{deps: Deps{
|
||||
ProcessAlive: func(int) bool { return false },
|
||||
Now: func() time.Time { return time.Unix(200, 0).UTC() },
|
||||
Sleep: func(time.Duration) {},
|
||||
}.withDefaults()}
|
||||
|
||||
st, err := c.waitForStopped(context.Background(), stoppedPID, runFile, dir, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State != "stopped" {
|
||||
t.Fatalf("state = %q, want stopped", st.State)
|
||||
}
|
||||
info, err := runfile.Read(runFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info != nil {
|
||||
t.Fatalf("own run-file should have been removed, got %#v", info)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ func Run() error {
|
|||
return err
|
||||
}
|
||||
|
||||
log := NewLogger()
|
||||
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
|
||||
|
|
@ -119,8 +119,8 @@ func Run() error {
|
|||
return runErr
|
||||
}
|
||||
|
||||
// NewLogger returns the daemon's slog logger. It writes to stderr so supervisors
|
||||
// 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 {
|
||||
func newLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package httpd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
||||
)
|
||||
|
||||
// TestShutdownGuard verifies that POST /shutdown only fires for a trusted local
|
||||
// caller: a loopback Host with no Origin header. A cross-site Origin or a
|
||||
// non-loopback (DNS-rebinding) Host must be rejected without triggering the
|
||||
// shutdown side effect.
|
||||
func TestShutdownGuard(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
host string
|
||||
origin string
|
||||
wantStatus int
|
||||
wantFired bool
|
||||
}{
|
||||
{name: "loopback no origin", host: "127.0.0.1:3001", wantStatus: http.StatusAccepted, wantFired: true},
|
||||
{name: "localhost no origin", host: "localhost:3001", wantStatus: http.StatusAccepted, wantFired: true},
|
||||
{name: "cross-site origin", host: "127.0.0.1:3001", origin: "https://evil.example", wantStatus: http.StatusForbidden, wantFired: false},
|
||||
{name: "rebinding host", host: "evil.example", wantStatus: http.StatusForbidden, wantFired: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fired := false
|
||||
r := NewRouterWithControl(config.Config{}, discardLogger(), nil, APIDeps{}, ControlDeps{
|
||||
RequestShutdown: func() { fired = true },
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "http://"+tc.host+"/shutdown", nil)
|
||||
req.Host = tc.host
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
if fired != tc.wantFired {
|
||||
t.Fatalf("shutdown fired = %v, want %v", fired, tc.wantFired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ package httpd
|
|||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
|
|
@ -76,11 +77,22 @@ func mountHealth(r chi.Router) {
|
|||
r.Get("/readyz", handleReadyz)
|
||||
}
|
||||
|
||||
// mountControl registers the loopback daemon-control endpoints. /shutdown is
|
||||
// unauthenticated and state-changing, so it is gated by localControlRequest to
|
||||
// keep a browser the user happens to have open (CSRF / DNS-rebinding) or a
|
||||
// remote client from being able to kill the daemon.
|
||||
func mountControl(r chi.Router, deps ControlDeps) {
|
||||
if deps.RequestShutdown == nil {
|
||||
return
|
||||
}
|
||||
r.Post("/shutdown", func(w http.ResponseWriter, _ *http.Request) {
|
||||
r.Post("/shutdown", func(w http.ResponseWriter, req *http.Request) {
|
||||
if !localControlRequest(req) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{
|
||||
"status": "forbidden",
|
||||
"service": daemonmeta.ServiceName,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "shutting_down",
|
||||
"service": daemonmeta.ServiceName,
|
||||
|
|
@ -90,6 +102,29 @@ func mountControl(r chi.Router, deps ControlDeps) {
|
|||
})
|
||||
}
|
||||
|
||||
// localControlRequest reports whether a control request is a trusted local
|
||||
// caller. The Go CLI client addresses the daemon by its loopback host and
|
||||
// never sets an Origin header; a cross-site browser fetch always carries an
|
||||
// Origin, and a DNS-rebinding attempt resolves a non-loopback Host. Rejecting
|
||||
// either closes the CSRF/rebinding vector while leaving the CLI unaffected.
|
||||
func localControlRequest(r *http.Request) bool {
|
||||
if r.Header.Get("Origin") != "" {
|
||||
return false
|
||||
}
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
switch host {
|
||||
case "127.0.0.1", "::1", "localhost":
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleHealthz is the liveness probe: it answers 200 as long as the process is
|
||||
// up and serving. It does no dependency checks by design.
|
||||
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,13 @@ 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 stop` gracefully stops the daemon via the loopback `POST /shutdown`
|
||||
endpoint, only after verifying the daemon's identity from `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 doctor` (and `ao doctor --json`) checks config, data dir, the database
|
||||
file's presence, daemon state, and local tool availability for `git`, `tmux`,
|
||||
and `zellij`. It never opens or migrates the store — the daemon is the sole
|
||||
writer/migrator, so doctor only reports whether the database exists yet.
|
||||
- `ao completion` generates shell completions for `bash`, `zsh`, `fish`, and
|
||||
`powershell`.
|
||||
- `ao version` and `ao --version` print build metadata.
|
||||
|
|
@ -52,8 +55,9 @@ What is intentionally not implemented yet:
|
|||
|
||||
Next steps:
|
||||
|
||||
1. Add `/api/v1/projects` on the daemon over a small project service.
|
||||
2. Implement `ao project list/add/show/remove`.
|
||||
1. Wire the existing project manager/controller shell into the daemon with a
|
||||
durable SQLite-backed project store.
|
||||
2. Implement `ao project list/add/show/remove` against `/api/v1/projects`.
|
||||
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
|
||||
|
|
@ -281,8 +285,8 @@ Acceptance criteria for the foundation:
|
|||
## 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`.
|
||||
what still needs to be built. Inventory date: 2026-05-31 after merging
|
||||
`origin/main` at `438b830`.
|
||||
|
||||
### Implemented Foundation
|
||||
|
||||
|
|
@ -298,8 +302,9 @@ Implemented commands:
|
|||
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 doctor` checks config, data dir, database-file presence, daemon state, and
|
||||
local tool availability for `git`, `tmux`, and `zellij`; supports `--json`. It
|
||||
does not open or migrate the store (the daemon owns that).
|
||||
- `ao completion` generates `bash`, `zsh`, `fish`, and `powershell`
|
||||
completions.
|
||||
- `ao version` prints build metadata.
|
||||
|
|
@ -331,7 +336,7 @@ 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. |
|
||||
| Project API pieces | `internal/project` has manager/controller DTOs, `/api/v1/projects` routes exist, and `sqlite.Store` has project CRUD. | Durable project-store adapter/wiring in the daemon and CLI commands. The daemon currently constructs the router with nil API deps, so project routes are not product-usable from `ao` yet. |
|
||||
| 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. |
|
||||
|
|
@ -345,12 +350,10 @@ 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. |
|
||||
| Product API client package with run-file discovery. | `project`, `spawn`, `session`, `send`, `events list`, richer `status`. |
|
||||
| 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`. |
|
||||
| Session/send API route surface under `/api/v1`. | `spawn`, `session`, `send`, richer `status`. |
|
||||
| Project API daemon wiring. | `ao project list/add/show/remove`. |
|
||||
| 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. |
|
||||
|
|
@ -368,10 +371,10 @@ These are the main gaps before the full initial command set is real.
|
|||
| `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 doctor` | Partially implemented | Config resolution, run-file, database-file presence (no open/migrate), runtime binary checks. | Deeper adapter preflights need daemon wiring/config and should be queried from the daemon, not run in-process. |
|
||||
| `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 project list/add/show/remove` | Not yet | Project manager/controller route shell and SQLite project CRUD exist. | Durable project-store adapter, daemon API wiring, and CLI HTTP client. 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. |
|
||||
|
|
@ -383,8 +386,8 @@ These are the main gaps before the full initial command set is real.
|
|||
|
||||
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`.
|
||||
2. Wire the existing project manager/controller shell into the daemon with a
|
||||
durable SQLite-backed store, 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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue