diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index 1ab4012fa..4002582ba 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -1,18 +1,32 @@ name: Desktop release # Builds and publishes the Electron desktop app via electron-forge. -# Generates a GitHub Release (draft) with installers + update manifests. -# Triggered by a `desktop-v*` tag or manually. +# Generates a GitHub Release with installers + update manifests, then uploads +# stable, version-free asset aliases so `ao start`'s constant +# releases/latest/download/ URL resolves (spec §8, §11.4). # # Each target OS builds on its own runner so the bundled `ao` daemon is compiled # natively for that platform. build-daemon.mjs keys the binary off the build # host's platform, so cross-OS packaging (e.g. building the Windows installer on # macOS) would ship a non-Windows binary named `ao` and the app could not launch -# the daemon (issues #235/#256). The per-OS matrix keeps host == target. +# the daemon (issues #235/#256). The per-OS matrix keeps host == target, and the +# Linux daemon is built on the Linux runner (AgentWrapper/agent-orchestrator#2191). # # ⚠️ Until macOS code signing + notarization secrets are configured (see # frontend/docs/desktop-release.md), published builds are UNSIGNED and will # NOT auto-update on macOS. The workflow still produces installable artifacts. +# +# ── Cutting a FORK test release (dev loop) ──────────────────────────────────── +# Test releases go to harshitsinghbhandari/agent-orchestrator (the fork), never +# to AgentWrapper. To cut one, on the FORK: +# - push a `desktop-v*` tag (e.g. `git push fork desktop-v0.0.0-test1`), or +# - run this workflow via `workflow_dispatch` from the fork's Actions tab. +# AO_RELEASE_REPO MUST be set to the fork for a test run. It is derived +# automatically below from `github.repository`, so a fork run publishes to the +# fork and a run on AgentWrapper publishes to AgentWrapper, with no edit. The +# matching test `ao` binary is built with +# `-ldflags -X ...cli.releaseRepo=harshitsinghbhandari/agent-orchestrator` so it +# fetches from the same fork (spec §6.3). on: push: @@ -25,7 +39,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest] + os: [macos-latest, windows-latest, ubuntu-latest] runs-on: ${{ matrix.os }} permissions: contents: write @@ -50,6 +64,10 @@ jobs: run: npm run publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Release target: the repo this run lives on. On the fork this is + # harshitsinghbhandari/agent-orchestrator; on AgentWrapper it is prod. + # forge.config.ts parses "owner/repo" into the publisher's repository. + AO_RELEASE_REPO: ${{ github.repository }} # macOS signing + notarization — add as repository secrets and # set osxSign/osxNotarize in forge.config.ts to enable. CSC_LINK: ${{ secrets.CSC_LINK }} @@ -57,3 +75,57 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Upload stable, version-free asset aliases for this runner's platform so + # `ao start`'s constant releases/latest/download/ URL resolves. The + # forge makers emit versioned, productName-derived filenames; we copy them + # to the space-free names `ao start` fetches and upload to the v + # release the publish step just created, with --clobber (so re-runs replace). + # + # Arch-coverage note: each runner only builds its host arch, so macos-latest + # (Apple silicon) produces darwin-arm64 only. The darwin-x64 alias needs an + # Intel macOS runner (e.g. macos-13) and is NOT produced here (gap, spec §8). + # The Linux stable name is undecided (spec §11.3), so we build the deb/rpm + # but intentionally upload no Linux alias yet. + - name: Upload stable asset aliases (macOS) + if: matrix.os == 'macos-latest' + shell: bash + run: | + # electron-forge publisher-github creates the release as v (PublisherGithub.js: `${tagPrefix ?? 'v'}${version}`), NOT the + # triggering git tag. Target that release, not GITHUB_REF_NAME, or the + # upload hits a non-existent release. + tag="v$(node -p "require('./package.json').version")" + arch="$(uname -m)" + case "$arch" in + arm64) alias_name="agent-orchestrator-darwin-arm64.zip" ;; + x86_64) alias_name="agent-orchestrator-darwin-x64.zip" ;; + *) echo "unsupported macOS arch: $arch" >&2; exit 1 ;; + esac + # maker-zip output: out/make/zip/darwin//-.zip + src="$(ls out/make/zip/darwin/*/*.zip | head -n1)" + if [ -z "$src" ]; then echo "no macOS zip found under out/make/zip/darwin" >&2; exit 1; fi + cp "$src" "$alias_name" + gh release upload "$tag" "$alias_name" --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload stable asset aliases (Windows) + if: matrix.os == 'windows-latest' + shell: bash + run: | + # Forge publishes to v (see the macOS step), not the git tag. + tag="v$(node -p "require('./package.json').version")" + alias_name="agent-orchestrator-win32-x64.exe" + # NSIS (electron-builder) output: out/make/ Setup .exe + src="$(ls "out/make/"*Setup*.exe | head -n1)" + if [ -z "$src" ]; then echo "no NSIS installer found under out/make" >&2; exit 1; fi + cp "$src" "$alias_name" + gh release upload "$tag" "$alias_name" --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Linux: deb/rpm build on this runner (issue #2191), but the stable asset + # name for the fetch-and-run bootstrapper is still undecided (deb/rpm vs + # AppImage, spec §11.3). No alias is uploaded until that decision lands; the + # versioned deb/rpm are still published by the forge publisher above. diff --git a/README.md b/README.md index 2d9f4dd2a..e29be79f7 100644 --- a/README.md +++ b/README.md @@ -55,17 +55,17 @@ ## Features -| Feature | Description | -| :--- | :--- | -| **Agent-Agnostic Platform** | 23+ agent adapters including [Claude Code](https://code.claude.com/docs/en/overview), [OpenAI Codex](https://openai.com/), [Cursor](https://cursor.com/), [OpenCode](https://opencode.ai/), [Aider](https://aider.chat/), [Amp](https://ampcode.com/manual), [Goose](https://goose-docs.ai/), [GitHub Copilot](https://github.com/features/copilot), [Grok](https://x.ai/grok), [Qwen Code](https://github.com/QwenLM/qwen-code), [Kimi Code](https://www.kimi.com/code), [Cline](https://cline.bot/), [Continue](https://www.continue.dev/), [Kiro](https://kiro.dev/), and more | -| **Isolated Workspaces** | Each session spawns into its own git worktree with dedicated runtime | -| **Platform-Native Runtimes** | tmux on Darwin/Linux, conpty on Windows for optimal performance | -| **Live PR Observation** | Provider-neutral SCM observer with automatic feedback routing | -| **Automatic Feedback Routing** | CI failures, review comments, and merge conflicts routed to the owning agent | -| **Durable Facts Storage** | SQLite persists immutable facts with display status derived at read time | -| **CDC Broadcasting** | DB triggers append changes to change_log, broadcasted via SSE | -| **Desktop Experience** | Native Electron app with React UI and live terminal streaming | -| **Loopback-Only Daemon** | HTTP control over 127.0.0.1 with no auth, CORS, or TLS by design | +| Feature | Description | +| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Agent-Agnostic Platform** | 23+ agent adapters including [Claude Code](https://code.claude.com/docs/en/overview), [OpenAI Codex](https://openai.com/), [Cursor](https://cursor.com/), [OpenCode](https://opencode.ai/), [Aider](https://aider.chat/), [Amp](https://ampcode.com/manual), [Goose](https://goose-docs.ai/), [GitHub Copilot](https://github.com/features/copilot), [Grok](https://x.ai/grok), [Qwen Code](https://github.com/QwenLM/qwen-code), [Kimi Code](https://www.kimi.com/code), [Cline](https://cline.bot/), [Continue](https://www.continue.dev/), [Kiro](https://kiro.dev/), and more | +| **Isolated Workspaces** | Each session spawns into its own git worktree with dedicated runtime | +| **Platform-Native Runtimes** | tmux on Darwin/Linux, conpty on Windows for optimal performance | +| **Live PR Observation** | Provider-neutral SCM observer with automatic feedback routing | +| **Automatic Feedback Routing** | CI failures, review comments, and merge conflicts routed to the owning agent | +| **Durable Facts Storage** | SQLite persists immutable facts with display status derived at read time | +| **CDC Broadcasting** | DB triggers append changes to change_log, broadcasted via SSE | +| **Desktop Experience** | Native Electron app with React UI and live terminal streaming | +| **Loopback-Only Daemon** | HTTP control over 127.0.0.1 with no auth, CORS, or TLS by design | ### Supported Agents @@ -80,13 +80,14 @@ Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Curs ### Prerequisites | Requirement | Minimum | Recommended | -|-------------|---------|-------------| -| Go | 1.25+ | Latest | -| Node.js | 20+ | Latest LTS | -| Git | Any | Latest | -| pnpm | Any | Latest | +| ----------- | ------- | ----------- | +| Go | 1.25+ | Latest | +| Node.js | 20+ | Latest LTS | +| Git | Any | Latest | +| pnpm | Any | Latest | **Optional:** + - `tmux` (Darwin/Linux) - For Unix runtime - `gh` (GitHub CLI) - For authenticated GitHub API calls @@ -94,11 +95,11 @@ Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Curs Download the latest release for your platform: -| Platform | Download | -|----------|----------| -| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) | -| **macOS** | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) | -| **Linux** | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) | +| Platform | Download | +| ----------- | ------------------------------------------------------------------------------------------------- | +| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) | +| **macOS** | [Agent Orchestrator.dmg](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) | +| **Linux** | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) | **Direct Download:** [Latest Release](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) @@ -117,6 +118,7 @@ Agent Orchestrator is a long-running Go daemon built around **inbound/outbound p **Core mental model:** OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT **Key components:** + - **Frontend** - Electron + React UI with TanStack Router/Query and shadcn/ui - **Backend Daemon** - Go-based HTTP server with controllers, services, and adapters - **Runtime** - Platform-specific: `tmux` on Darwin/Linux, `conpty` on Windows @@ -129,12 +131,12 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc ## Documentation -| Document | Description | -|----------|-------------| -| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | -| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | -| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | -| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior | +| Document | Description | +| -------------------------------------------------------- | ------------------------------------------------------- | +| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | +| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | +| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | +| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior | --- @@ -159,15 +161,15 @@ npx @redwoodjs/agent-ci run --all All configuration is environment-driven. The daemon takes no config file. -| Variable | Default | Purpose | -|----------|---------|---------| -| `AO_PORT` | `3001` | HTTP bind port | -| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout | -| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap | -| `AO_RUN_FILE` | `~/.ao/running.json` | PID/port handshake | -| `AO_DATA_DIR` | `~/.ao/data` | SQLite data directory | -| `AO_AGENT` | `claude-code` | Compatibility agent adapter | -| `GITHUB_TOKEN` | - | GitHub auth token | +| Variable | Default | Purpose | +| --------------------- | -------------------- | --------------------------- | +| `AO_PORT` | `3001` | HTTP bind port | +| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout | +| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap | +| `AO_RUN_FILE` | `~/.ao/running.json` | PID/port handshake | +| `AO_DATA_DIR` | `~/.ao/data` | SQLite data directory | +| `AO_AGENT` | `claude-code` | Compatibility agent adapter | +| `GITHUB_TOKEN` | - | GitHub auth token | ### Health Checks diff --git a/backend/internal/cli/e2e_test.go b/backend/internal/cli/e2e_test.go index e762627bd..f22ca1192 100644 --- a/backend/internal/cli/e2e_test.go +++ b/backend/internal/cli/e2e_test.go @@ -135,14 +135,32 @@ func asExit(err error, target **exec.ExitError) bool { return false } -// startDaemon brings the daemon up and registers a stop on cleanup. +// startDaemon brings the daemon up and registers a stop on cleanup. `ao start` +// no longer spawns the daemon (the desktop app owns it now), so the e2e suite +// drives the hidden `ao daemon` command directly and polls for readiness. func (e env) startDaemon(t *testing.T) { t.Helper() - out, code := e.run(t, "start") - if code != 0 { - t.Fatalf("start failed (exit %d): %s", code, out) + cmd := exec.Command(aoBin, "daemon") + cmd.Env = e.environ("") + if err := cmd.Start(); err != nil { + t.Fatalf("spawn ao daemon: %v", err) + } + t.Cleanup(func() { + e.run(t, "stop") + _, _ = cmd.Process.Wait() + }) + + deadline := time.Now().Add(10 * time.Second) + for { + out, _ := e.run(t, "status", "--json") + if strings.Contains(out, `"state": "ready"`) { + return + } + if time.Now().After(deadline) { + t.Fatalf("daemon did not become ready within 10s; last status:\n%s", out) + } + time.Sleep(100 * time.Millisecond) } - t.Cleanup(func() { e.run(t, "stop") }) } func mustContain(t *testing.T, out, want string) { @@ -224,12 +242,7 @@ func TestE2E_Lifecycle(t *testing.T) { mustContain(t, out, `"state": "ready"`) mustContain(t, out, fmt.Sprintf(`"port": %d`, e.port)) - // idempotent - if out, code := e.run(t, "start"); code != 0 || !strings.Contains(out, "ready") { - t.Fatalf("idempotent start: exit %d, out %s", code, out) - } - - // now the daemon (not the CLI) has created + migrated the store + // the daemon (not the CLI) has created + migrated the store if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err != nil { t.Fatalf("daemon should have created ao.db: %v", err) } diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index 21b405401..a5c1379ab 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -131,144 +131,6 @@ func TestStatusStoppedJSON(t *testing.T) { } } -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": - _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) - case "/readyz": - _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) - 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) error { - started = true - return 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 TestStartClearsStaleRunFileBeforeSpawning(t *testing.T) { - cfg := setConfigEnv(t) - var spawned atomic.Bool - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !spawned.Load() { - _, _ = fmt.Fprintf(w, `{"status":"ok","service":"not-ao","pid":4242}`) - return - } - switch r.URL.Path { - case "/healthz": - _, _ = fmt.Fprintf(w, `{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) - case "/readyz": - _, _ = fmt.Fprintf(w, `{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid()) - default: - http.NotFound(w, r) - } - })) - defer srv.Close() - - port := serverPort(t, srv.URL) - if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: port, StartedAt: time.Unix(100, 0).UTC()}); err != nil { - t.Fatal(err) - } - - out, _, err := executeCLI(t, Deps{ - ProcessAlive: func(pid int) bool { return pid == 4242 || pid == os.Getpid() }, - StartProcess: func(processStartConfig) error { - info, err := runfile.Read(cfg.runFile) - if err != nil { - t.Fatal(err) - } - if info != nil { - t.Fatalf("stale run-file was not removed before spawn: %#v", info) - } - spawned.Store(true) - if err := runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}); err != nil { - t.Fatal(err) - } - return nil - }, - Now: func() time.Time { return time.Unix(120, 0).UTC() }, - }, "start", "--json") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, `"state": "ready"`) { - t.Fatalf("start did not report ready after clearing stale run-file:\n%s", out) - } -} - -func TestStartEmitsCLIInvocationAfterReady(t *testing.T) { - cfg := setConfigEnv(t) - var spawned atomic.Bool - called := make(chan string, 1) - port := 3001 - out, _, err := executeCLI(t, Deps{ - HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - switch req.URL.Path { - case "/healthz": - if !spawned.Load() { - return jsonResponse(http.StatusOK, `{"status":"ok","service":"not-ao","pid":4242}`), nil - } - return jsonResponse(http.StatusOK, fmt.Sprintf(`{"status":"ok","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())), nil - case "/readyz": - if !spawned.Load() { - return jsonResponse(http.StatusOK, `{"status":"not_ready","service":"not-ao","pid":4242}`), nil - } - return jsonResponse(http.StatusOK, fmt.Sprintf(`{"status":"ready","service":%q,"pid":%d}`, daemonmeta.ServiceName, os.Getpid())), nil - case "/internal/telemetry/cli-invoked": - called <- req.URL.Path - return jsonResponse(http.StatusAccepted, ""), nil - default: - return jsonResponse(http.StatusNotFound, ""), nil - } - })}, - ProcessAlive: func(pid int) bool { return pid == os.Getpid() }, - StartProcess: func(processStartConfig) error { - spawned.Store(true) - return runfile.Write(cfg.runFile, runfile.Info{PID: os.Getpid(), Port: port, StartedAt: time.Unix(110, 0).UTC()}) - }, - Now: func() time.Time { return time.Unix(120, 0).UTC() }, - }, "start", "--json") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, `"state": "ready"`) { - t.Fatalf("start did not report ready:\n%s", out) - } - select { - case path := <-called: - if path != "/internal/telemetry/cli-invoked" { - t.Fatalf("telemetry path = %q, want /internal/telemetry/cli-invoked", path) - } - default: - t.Fatal("start did not emit CLI invocation after readiness") - } -} - 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 { @@ -429,28 +291,6 @@ func TestStopRefusesUnverifiedLivePID(t *testing.T) { } } -func TestStartDoesNotSpawnWhenLiveProbeFails(t *testing.T) { - cfg := setConfigEnv(t) - if err := runfile.Write(cfg.runFile, runfile.Info{PID: 4242, Port: closedPort(t), StartedAt: time.Unix(100, 0).UTC()}); err != nil { - t.Fatal(err) - } - - var started bool - _, _, err := executeCLI(t, Deps{ - ProcessAlive: func(pid int) bool { return pid == 4242 }, - StartProcess: func(processStartConfig) error { - started = true - return nil - }, - }, "start", "--timeout", "1ns", "--json") - if err == nil { - t.Fatal("start should fail instead of spawning over a live unverified PID") - } - if started { - t.Fatal("start spawned while run-file PID was still alive") - } -} - type testConfig struct { runFile string dataDir string diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index f8f0d1d30..c217dfb84 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -2,196 +2,330 @@ package cli import ( "context" + "encoding/json" "fmt" + "io" + "net/http" "os" "path/filepath" - "time" + "runtime" "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" - "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" - "github.com/aoagents/agent-orchestrator/backend/internal/runfile" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" ) -const defaultStartTimeout = 10 * time.Second +// releaseRepo is the GitHub "owner/repo" that `ao start` fetches the desktop app +// from. It defaults to the production target and is overridable at build time so +// a test binary fetches from the fork without a source edit: +// +// go build -ldflags "-X github.com/aoagents/agent-orchestrator/backend/internal/cli.releaseRepo=harshitsinghbhandari/agent-orchestrator" ./cmd/ao +// +// Mirrors how version.go's Version var is stamped by release tooling. +var releaseRepo = "AgentWrapper/agent-orchestrator" + +// appBundleName is the macOS bundle directory name produced by electron-forge +// (spaced, per frontend/forge.config.ts). +const appBundleName = "Agent Orchestrator.app" + +// appStateFileName is the marker the desktop app writes under ~/.ao on every +// launch (spec §5). `ao start` is a read-only consumer of it. +const appStateFileName = "app-state.json" + +// appState mirrors the app-written ~/.ao/app-state.json marker (spec §5). Only +// the desktop app writes it; `ao start` reads it as a fast-path hint and never +// trusts appPath without stat-ing it (invariant 2). +type appState struct { + SchemaVersion int `json:"schemaVersion"` + AppPath string `json:"appPath"` + Version string `json:"version"` + InstalledAt string `json:"installedAt"` + LastReconciledAt string `json:"lastReconciledAt"` + InstallSource string `json:"installSource"` +} type startOptions struct { - timeout time.Duration - logFile string - json bool + json bool +} + +// startResult is the JSON shape emitted with --json: what `ao start` resolved, +// whether it fetched, whether it opened, and the resulting bundle path. +type startResult struct { + Resolved bool `json:"resolved"` + Fetched bool `json:"fetched"` + Opened bool `json:"opened"` + AppPath string `json:"appPath"` } func newStartCommand(ctx *commandContext) *cobra.Command { - opts := startOptions{timeout: defaultStartTimeout} + opts := startOptions{} cmd := &cobra.Command{ Use: "start", - Short: "Start the AO daemon", - Args: noArgs, + Short: "Fetch (if needed) and open the Agent Orchestrator desktop app", + Long: "Fetch (if needed) and open the Agent Orchestrator desktop app.\n\n" + + "The desktop app now owns the daemon, state, and updates. `ao start` no\n" + + "longer runs a daemon: it resolves the installed app (or downloads the\n" + + "latest release), opens it, and exits.", + Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { - st, err := ctx.startDaemon(cmd.Context(), opts) - if err != nil { - return err - } - ctx.emitCLIInvoked(cmd.Context(), cmd) - if opts.json { - return writeJSON(cmd.OutOrStdout(), st) - } - if st.State == stateReady { - _, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port) - return err - } - return writeStatus(cmd, st) + return ctx.runStart(cmd.Context(), cmd, opts) }, } - 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) { +// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; standalone `ao import` still available. + +// runStart implements the spec §6.1 algorithm: resolve the installed app, fetch +// it if absent, open it, then print the deprecation notice. It never blocks or +// supervises the launched app. +func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts startOptions) error { + out := cmd.OutOrStdout() + res := startResult{} + + appPath, err := c.resolveApp() + if err != nil { + return err + } + res.Resolved = appPath != "" + + if appPath == "" { + appPath, err = c.fetchApp(ctx) + if err != nil { + return err + } + res.Fetched = true + } + res.AppPath = appPath + + opened, err := c.openApp(ctx, appPath) + if err != nil { + return err + } + res.Opened = opened + + if opts.json { + return writeJSON(out, res) + } + + c.printDeprecationNotice(out) + if !opened { + c.printManualOpen(out, appPath) + } + return nil +} + +// resolveApp returns the path to a usable desktop bundle, or "" when none is +// found (spec §6.2). Resolution order is fixed: marker path -> stat -> known +// location scan. It never compares versions (invariant 5). +func (c *commandContext) resolveApp() (string, error) { + if p := c.markerAppPath(); p != "" && isUsableBundle(p) { + return p, nil + } + for _, p := range appScanLocations() { + if isUsableBundle(p) { + return p, nil + } + } + return "", nil +} + +// appScanLocations is the known-location scan source. It is a package var so +// tests can point the scan at a temp bundle instead of real system paths. +var appScanLocations = knownAppLocations + +// markerAppPath reads ~/.ao/app-state.json and returns its recorded appPath, or +// "" if the marker is missing/unreadable. It does not stat the path; callers do. +func (c *commandContext) markerAppPath() string { + dir, err := aoStateDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(dir, appStateFileName)) + if err != nil { + return "" + } + var st appState + if err := json.Unmarshal(data, &st); err != nil { + return "" + } + return st.AppPath +} + +// aoStateDir resolves the canonical ~/.ao home, honoring AO_DATA_DIR exactly as +// the daemon's config does (the marker lives beside running.json under ~/.ao). +func aoStateDir() (string, error) { cfg, err := config.Load() if err != nil { - return daemonStatus{}, err + return "", err } - - st, err := c.inspectDaemon(ctx) - if err != nil { - return daemonStatus{}, err - } - if st.State == stateReady { - return st, nil - } - if st.State != stateStopped && st.State != stateStale { - ready, waitErr := c.waitForReady(ctx, opts.timeout) - if waitErr == nil { - return ready, nil - } - return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr) - } - if st.State == stateStale { - if err := runfile.Remove(cfg.RunFilePath); err != nil { - return daemonStatus{}, err - } - } - - // First-boot opt-in: before launching the daemon (so the import runs with the - // store unlocked and the daemon as sole writer afterwards), offer to import a - // legacy AO install. Declining or any import failure is non-fatal — the - // daemon still starts and the user can run `ao import` later. - c.maybeFirstBootImport(ctx, cfg) - - exe, err := c.deps.Executable() - if err != nil { - return daemonStatus{}, fmt.Errorf("resolve executable: %w", err) - } - - logPath := opts.logFile - if logPath == "" { - logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log") - } - if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil { - return daemonStatus{}, fmt.Errorf("create log dir: %w", err) - } - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return daemonStatus{}, fmt.Errorf("open daemon log: %w", err) - } - defer func() { _ = logFile.Close() }() - - if err := c.deps.StartProcess(processStartConfig{ - Path: exe, - Args: []string{"daemon"}, - Env: os.Environ(), - Stdout: logFile, - Stderr: logFile, - }); err != nil { - return daemonStatus{}, fmt.Errorf("start daemon: %w", err) - } - - ready, err := c.waitForReady(ctx, opts.timeout) - if err != nil { - return daemonStatus{}, fmt.Errorf("%w; see daemon log %s", err, logPath) - } - return ready, nil + // running.json lives directly under ~/.ao; the marker sits beside it. + return filepath.Dir(cfg.RunFilePath), nil } -// maybeFirstBootImport offers to import a legacy AO install the first time the -// daemon is started against an empty rewrite database. It is best-effort: every -// failure path degrades to "start the daemon fresh" so a broken or absent legacy -// store can never block startup. A non-interactive boot (Electron/headless) -// never auto-imports; it prints a one-line hint to run `ao import` explicitly. -func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Config) { - root := legacyimport.DefaultLegacyRootDir() - if !legacyimport.HasLegacyData(root) { - return +// knownAppLocations lists the platform's standard install paths to scan when the +// marker misses (covers website installs and stale markers, spec §6.2). +func knownAppLocations() []string { + switch runtime.GOOS { + case "darwin": + paths := []string{filepath.Join("/Applications", appBundleName)} + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, "Applications", appBundleName)) + } + return paths + default: + // Windows/Linux scan locations land in T6/T7. + return nil } - - store, err := sqlite.Open(cfg.DataDir) - if err != nil { - return // the daemon will surface a real store error on its own open - } - defer func() { _ = store.Close() }() - - projects, err := store.ListProjects(ctx) - if err != nil || len(projects) > 0 { - // Already imported (or populated) — don't offer again. - return - } - - out := c.deps.Out - if !stdinIsInteractive(c.deps.In) { - _, _ = fmt.Fprintf(out, "Found existing AO projects at %s. Run `ao import` to bring them in.\n", root) - return - } - - ok, err := confirm(c.deps.In, out, "Found existing AO projects and sessions. Import them now?", true) - if err != nil || !ok { - _, _ = fmt.Fprintln(out, "Continuing fresh. Run `ao import` later to bring in your existing data.") - return - } - - rep, err := legacyimport.Run(ctx, store, legacyimport.Options{Root: root, DataDir: cfg.DataDir}) - if err != nil { - _, _ = fmt.Fprintf(out, "Import failed: %v\nContinuing fresh; legacy data is untouched. Retry with `ao import`.\n", err) - return - } - _ = writeImportSummary(out, rep) } -func (c *commandContext) waitForReady(ctx context.Context, timeout time.Duration) (daemonStatus, error) { - if timeout <= 0 { - timeout = defaultStartTimeout +// isUsableBundle reports whether p stats as a usable app bundle. On macOS a +// bundle is a directory; the filesystem is the source of truth (invariant 2). +func isUsableBundle(p string) bool { + if p == "" { + return false } - deadline := c.deps.Now().Add(timeout) - var last daemonStatus - var lastErr error + info, err := os.Stat(p) + if err != nil { + return false + } + if runtime.GOOS == "darwin" { + return info.IsDir() + } + return true +} - for { - select { - case <-ctx.Done(): - return daemonStatus{}, ctx.Err() - default: - } +// fetchApp downloads the latest desktop release for this platform, unpacks it +// into a staging dir under ~/.ao/staging, and returns the bundle path (spec +// §6.3). Windows/Linux are tracked as T6/T7. +func (c *commandContext) fetchApp(ctx context.Context) (string, error) { + if runtime.GOOS != "darwin" { + return "", fmt.Errorf("ao start: fetch not yet implemented for %s (tracked as spec T6/T7)", runtime.GOOS) + } - st, err := c.inspectDaemon(ctx) - if err != nil { - lastErr = err - } else { - last = st - if st.State == stateReady { - return st, nil - } - } + asset, err := darwinAssetName() + if err != nil { + return "", err + } + url := downloadURL(asset) - 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) + stateDir, err := aoStateDir() + if err != nil { + return "", err + } + staging := filepath.Join(stateDir, "staging") + // Clear any stale or partial prior unpack so ditto extracts into a clean dir + // (a leftover bundle could otherwise merge with the new one). + if err := os.RemoveAll(staging); err != nil { + return "", fmt.Errorf("clear staging dir: %w", err) + } + if err := os.MkdirAll(staging, 0o750); err != nil { + return "", fmt.Errorf("create staging dir: %w", err) + } + + zipPath := filepath.Join(staging, asset) + if err := c.download(ctx, url, zipPath); err != nil { + return "", fmt.Errorf("download %s: %w", url, err) + } + + // ditto preserves the .app code signature; plain unzip corrupts it (spec §6.3). + if out, err := c.deps.CommandOutput(ctx, "ditto", "-x", "-k", zipPath, staging); err != nil { + return "", fmt.Errorf("ditto unpack: %w: %s", err, out) + } + + appPath := filepath.Join(staging, appBundleName) + if !isUsableBundle(appPath) { + return "", fmt.Errorf("ao start: %s not found in unpacked release at %s", appBundleName, staging) + } + return appPath, nil +} + +// download streams url to dst using the injected HTTP client. +func (c *commandContext) download(ctx context.Context, url, dst string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + // deps.HTTPClient carries a short (2s) timeout sized for loopback daemon + // probes; a release asset is hundreds of MB. Copy the client and drop the + // timeout, relying on ctx for cancellation. The Transport is preserved so + // tests still reach their httptest server. + client := *c.deps.HTTPClient + client.Timeout = 0 + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %s", resp.Status) + } + + f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + if _, err := io.Copy(f, resp.Body); err != nil { + return err + } + return f.Close() +} + +// darwinAssetName maps Go's runtime.GOARCH to the release asset name. The +// release pipeline publishes "x64" for amd64 (spec §6.3, §8). +func darwinAssetName() (string, error) { + arch, err := assetArch(runtime.GOARCH) + if err != nil { + return "", err + } + return fmt.Sprintf("agent-orchestrator-darwin-%s.zip", arch), nil +} + +// assetArch maps a Go GOARCH to the release-asset arch token. +func assetArch(goarch string) (string, error) { + switch goarch { + case "arm64": + return "arm64", nil + case "amd64": + return "x64", nil + default: + return "", fmt.Errorf("ao start: unsupported architecture %q", goarch) } } + +// downloadURL builds the constant releases/latest/download URL for asset. +func downloadURL(asset string) string { + return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", releaseRepo, asset) +} + +// openApp launches the resolved bundle detached and reports whether it launched +// (spec §6.5). It passes --installed-via=npm-bootstrap so the app can record the +// install source in its marker. It never waits on the app. +func (c *commandContext) openApp(ctx context.Context, appPath string) (bool, error) { + if runtime.GOOS != "darwin" { + // Non-darwin open lands in T6/T7; treat as "not opened" so the caller + // prints manual-open instructions. + return false, nil + } + // `open` returns immediately; --args forwards the rest to the app. + if out, err := c.deps.CommandOutput(ctx, "open", appPath, "--args", "--installed-via=npm-bootstrap"); err != nil { + return false, fmt.Errorf("open %s: %w: %s", appPath, err, out) + } + return true, nil +} + +// printDeprecationNotice explains the new role of the npm `ao` binary. Keep it +// honest: Track B (live auto-update) is not done, so it does not promise it. +func (c *commandContext) printDeprecationNotice(w io.Writer) { + _, _ = fmt.Fprint(w, "Agent Orchestrator is now a desktop app, and the npm `ao` is just its launcher.\n"+ + "The app is distributed from the website and GitHub Releases; it owns the daemon and updates itself.\n"+ + "You can keep running `ao start` to fetch (if needed) and open it.\n") +} + +// printManualOpen tells the user how to open the bundle when `ao start` could +// not launch it for them (non-darwin, or a failed launch handled upstream). +func (c *commandContext) printManualOpen(w io.Writer, appPath string) { + _, _ = fmt.Fprintf(w, "Could not open the app automatically. Open it manually: %s\n", appPath) +} diff --git a/backend/internal/cli/start_test.go b/backend/internal/cli/start_test.go new file mode 100644 index 000000000..52c5c8128 --- /dev/null +++ b/backend/internal/cli/start_test.go @@ -0,0 +1,197 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + "time" +) + +// writeMarker writes a ~/.ao/app-state.json marker pointing at appPath into the +// configured state dir (AO_RUN_FILE's directory). +func writeMarker(t *testing.T, cfg testConfig, appPath string) { + t.Helper() + st := appState{SchemaVersion: 1, AppPath: appPath, InstallSource: "npm-bootstrap"} + data, err := json.Marshal(st) + if err != nil { + t.Fatal(err) + } + dir := filepath.Dir(cfg.runFile) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, appStateFileName), data, 0o600); err != nil { + t.Fatal(err) + } +} + +// makeBundle creates a directory that stats as a usable bundle on every OS. +func makeBundle(t *testing.T, name string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.MkdirAll(p, 0o750); err != nil { + t.Fatal(err) + } + return p +} + +func TestResolveApp_MarkerHit(t *testing.T) { + cfg := setConfigEnv(t) + bundle := makeBundle(t, appBundleName) + writeMarker(t, cfg, bundle) + // No scan locations: a hit must come from the marker. + t.Cleanup(swapScanLocations(func() []string { return nil })) + + c := &commandContext{deps: Deps{}.withDefaults()} + got, err := c.resolveApp() + if err != nil { + t.Fatal(err) + } + if got != bundle { + t.Fatalf("resolveApp = %q, want marker path %q", got, bundle) + } +} + +func TestResolveApp_MarkerMissThenScanHit(t *testing.T) { + cfg := setConfigEnv(t) + // Marker points at a path that does not exist -> must fall through to scan. + writeMarker(t, cfg, filepath.Join(t.TempDir(), "gone", appBundleName)) + scanBundle := makeBundle(t, appBundleName) + t.Cleanup(swapScanLocations(func() []string { return []string{scanBundle} })) + + c := &commandContext{deps: Deps{}.withDefaults()} + got, err := c.resolveApp() + if err != nil { + t.Fatal(err) + } + if got != scanBundle { + t.Fatalf("resolveApp = %q, want scan path %q", got, scanBundle) + } +} + +func TestResolveApp_ScanMissReturnsEmpty(t *testing.T) { + setConfigEnv(t) // no marker written + t.Cleanup(swapScanLocations(func() []string { + return []string{filepath.Join(t.TempDir(), "nope", appBundleName)} + })) + + c := &commandContext{deps: Deps{}.withDefaults()} + got, err := c.resolveApp() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("resolveApp = %q, want empty", got) + } +} + +func TestAssetArchMapping(t *testing.T) { + cases := map[string]struct { + want string + wantErr bool + }{ + "arm64": {want: "arm64"}, + "amd64": {want: "x64"}, + "386": {wantErr: true}, + } + for goarch, tc := range cases { + got, err := assetArch(goarch) + if tc.wantErr { + if err == nil { + t.Errorf("assetArch(%q) = %q, want error", goarch, got) + } + continue + } + if err != nil { + t.Errorf("assetArch(%q): unexpected error %v", goarch, err) + } + if got != tc.want { + t.Errorf("assetArch(%q) = %q, want %q", goarch, got, tc.want) + } + } +} + +func TestDownloadURLUsesReleaseRepo(t *testing.T) { + orig := releaseRepo + releaseRepo = "owner/repo" + t.Cleanup(func() { releaseRepo = orig }) + + got := downloadURL("agent-orchestrator-darwin-arm64.zip") + want := "https://github.com/owner/repo/releases/latest/download/agent-orchestrator-darwin-arm64.zip" + if got != want { + t.Fatalf("downloadURL = %q, want %q", got, want) + } +} + +func TestOpenApp_ArgConstruction(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("openApp launches via `open` only on darwin") + } + var gotName string + var gotArgs []string + c := &commandContext{deps: Deps{ + CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) { + gotName = name + gotArgs = args + return nil, nil + }, + }.withDefaults()} + + opened, err := c.openApp(context.Background(), "/Applications/Agent Orchestrator.app") + if err != nil { + t.Fatal(err) + } + if !opened { + t.Fatal("openApp reported not opened") + } + if gotName != "open" { + t.Fatalf("command = %q, want open", gotName) + } + wantArgs := []string{"/Applications/Agent Orchestrator.app", "--args", "--installed-via=npm-bootstrap"} + if !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("args = %v, want %v", gotArgs, wantArgs) + } +} + +// TestDownload_IgnoresShortClientTimeout proves download() does not inherit the +// 2s deps.HTTPClient timeout (sized for loopback probes), which would otherwise +// fail every real release download. The server responds after a delay that +// exceeds the injected client's tiny timeout; download must still succeed. +func TestDownload_IgnoresShortClientTimeout(t *testing.T) { + const body = "release-zip-bytes" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(150 * time.Millisecond) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + c := &commandContext{deps: Deps{ + // 50ms timeout: if download honored this, the 150ms server would fail it. + HTTPClient: &http.Client{Timeout: 50 * time.Millisecond}, + }.withDefaults()} + + dst := filepath.Join(t.TempDir(), "out.zip") + if err := c.download(context.Background(), srv.URL, dst); err != nil { + t.Fatalf("download failed (short client timeout leaked into large-asset path?): %v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(got) != body { + t.Fatalf("downloaded %q, want %q", got, body) + } +} + +// swapScanLocations replaces the scan-location seam and returns a restore func. +func swapScanLocations(fn func() []string) func() { + orig := appScanLocations + appScanLocations = fn + return func() { appScanLocations = orig } +} diff --git a/docs/agent/README.md b/docs/agent/README.md index 6ddc3424c..eca64de39 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -118,6 +118,7 @@ flowchart LR ``` **Input (`LaunchConfig`):** + - `SessionID` — AO session identifier - `ProjectID` — AO project identifier - `Prompt` — User prompt to send @@ -159,6 +160,7 @@ flowchart TD ``` **Requirements:** + - Preserve all user hooks - Deduplicate AO hook entries - Make AO hooks machine-portable (use `ao hooks ...` command) @@ -169,11 +171,13 @@ flowchart TD Builds a command to resume an existing session: **Input (`RestoreConfig`):** + - `Session` — Complete session record with metadata - `Permissions` — Permission mode to apply - `SystemPrompt` — System prompt to re-apply **Output:** + - `cmd` — Restore command argv - `ok` — True if restore is supported - `err` — Error if restore fails @@ -242,13 +246,13 @@ sequenceDiagram AO supports hooks for these agent events: -| Event | Purpose | Activity State | -|-------|---------|----------------| -| `SessionStart` | Session initialized | - | -| `UserPromptSubmit` | User submitted prompt | `active` | -| `AssistantMessage` | Assistant response | - | -| `ToolUse` | Agent used a tool | `active` | -| `Stop` | Session stopped | `idle` or `exited` | +| Event | Purpose | Activity State | +| ------------------ | --------------------- | ------------------ | +| `SessionStart` | Session initialized | - | +| `UserPromptSubmit` | User submitted prompt | `active` | +| `AssistantMessage` | Assistant response | - | +| `ToolUse` | Agent used a tool | `active` | +| `Stop` | Session stopped | `idle` or `exited` | ### Hook Contract @@ -288,6 +292,7 @@ flowchart TD ``` **Critical rules:** + 1. **Always exit 0** — Hook failure must never break the user's agent 2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR` 3. **Best-effort delivery** — The daemon may be temporarily unavailable @@ -302,9 +307,9 @@ Different agents use different hook mechanisms: ```json // .claude/hooks.json { - "SessionStart": ["ao hooks claude-code SessionStart"], - "UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"], - "Stop": ["ao hooks claude-code Stop"] + "SessionStart": ["ao hooks claude-code SessionStart"], + "UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"], + "Stop": ["ao hooks claude-code Stop"] } ``` @@ -313,16 +318,16 @@ Different agents use different hook mechanisms: ```json // .factory/hooks.json { - "hooks": [ - { - "event": "agent:beforeThinking", - "command": "ao hooks droid beforeThinking" - }, - { - "event": "agent:afterThinking", - "command": "ao hooks droid afterThinking" - } - ] + "hooks": [ + { + "event": "agent:beforeThinking", + "command": "ao hooks droid beforeThinking" + }, + { + "event": "agent:afterThinking", + "command": "ao hooks droid afterThinking" + } + ] } ``` @@ -797,16 +802,16 @@ backend/internal/adapters/agent/ ### Adapter Capabilities -| Agent | Permissions | System Prompt | Restore | Hooks | -|-------|-------------|---------------|---------|-------| -| Claude Code | ✓ | ✓ | ✓ | ✓ | -| Codex | ✓ | ✓ | ✓ | ✓ (session flags) | -| Cursor | ✓ | ✓ | ✗ | ✗ | -| OpenCode | ✓ | ✓ | ✗ | ✗ | -| Aider | ✓ | ✓ | ✓ | ✓ | -| Amp | ✓ | ✓ | ✓ | ✓ | -| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) | -| ... | ... | ... | ... | ... | +| Agent | Permissions | System Prompt | Restore | Hooks | +| ----------- | ----------- | ------------- | ------- | ----------------- | +| Claude Code | ✓ | ✓ | ✓ | ✓ | +| Codex | ✓ | ✓ | ✓ | ✓ (session flags) | +| Cursor | ✓ | ✓ | ✗ | ✗ | +| OpenCode | ✓ | ✓ | ✗ | ✗ | +| Aider | ✓ | ✓ | ✓ | ✓ | +| Amp | ✓ | ✓ | ✓ | ✓ | +| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) | +| ... | ... | ... | ... | ... | --- diff --git a/docs/ao-start-bootstrapper-and-npm-deprecation.md b/docs/ao-start-bootstrapper-and-npm-deprecation.md new file mode 100644 index 000000000..9cf22b209 --- /dev/null +++ b/docs/ao-start-bootstrapper-and-npm-deprecation.md @@ -0,0 +1,450 @@ +# `ao start` Bootstrapper + npm Deprecation: Implementation Spec + +> **Status:** ready for build (Track A). Grounded against the real codebase on +> branch `feat/ao-start-bootstrapper` (= `upstream/main` + PR #2185) on 2026-06-26. +> Every "current state" claim carries a `file:line` reference. +> +> **This is NOT a new JS launcher package.** The `ao` binary that npm ships is the +> existing Go cobra CLI (`backend/cmd/ao`). This effort rewrites one subcommand, +> `ao start`, to fetch and open the desktop app. Everything else in the CLI is +> already wired and rides along. + +--- + +## 0. Goal + +npm `ao` is the **legacy on-ramp** for users who already have `ao` on their PATH. +We are deprecating npm as an app-distribution path: + +- `npm update` swaps in our **new Go `ao` binary** (the whole CLI), replacing the + old one in place. No fresh-install story; the audience is existing users. +- The **`ao start`** subcommand is rewritten: instead of starting a daemon, it + **fetches the desktop app from GitHub Releases and opens it**. +- The **desktop app owns the daemon**, auto-update, relocation, and all state. The + CLI becomes a thin client of the app-owned daemon. + +`ao start` is the one-time bridge that moves a CLI user onto the canonical, +auto-updating desktop build. It is dumb about versions: its only job is "is the +app present? if not, fetch it; then open it." + +--- + +## 1. Ground truth (what the code actually is today) + +### 1.1 App identity and release target + +| Fact | Value | Source | +| ---------------------------- | ---------------------------------------------------------------------- | ------------------------------- | +| Product / bundle name | **`Agent Orchestrator.app`** (spaced) | `frontend/forge.config.ts:9,50` | +| Bundle id | `dev.agent-orchestrator.desktop` | `frontend/forge.config.ts:8` | +| Executable name | `agent-orchestrator` | `frontend/forge.config.ts` | +| **Release repo (canonical)** | **`AgentWrapper/agent-orchestrator`** | per release owner | +| Forge publisher repo (TODAY) | `aoagents/agent-orchestrator` — **stale, must change to AgentWrapper** | `frontend/forge.config.ts:86` | +| GitHub release mode | **`draft: true`**, `prerelease: false` | `frontend/forge.config.ts` | + +> `aoagents/agent-orchestrator` was the **temporary** home during the rewrite; the +> code is now ported and releases land on **`AgentWrapper/agent-orchestrator`**. +> The forge publisher still points at `aoagents` and must be corrected (task T3). +> The Go **module path** is also `github.com/aoagents/agent-orchestrator`; renaming +> the module is a large, separate change and is **out of scope** here (it does not +> affect the release/download URL). + +### 1.2 Release / build pipeline + +- Workflow: `.github/workflows/frontend-release.yml`. Triggers: tag `desktop-v*`, + `workflow_dispatch`. Build: `npm run publish` → `build:daemon` + + `electron-forge publish`. +- **Matrix: `[macos-latest, windows-latest]` only** (`:28`) — no Linux; deb/rpm + makers configured but never run (upstream issue AgentWrapper/agent-orchestrator#2191). +- Maker outputs (today): macOS `@electron-forge/maker-zip` → versioned `.zip` + under `out/make/zip/darwin//`; Windows `MakerNSIS` → `Agent Orchestrator +Setup.exe` (per-user installer); Linux `maker-deb`/`maker-rpm` → + `agent-orchestrator-.{deb,rpm}`. +- **No asset-rename step** and **`draft: true`** → a constant + `releases/latest/download/` URL cannot resolve until both are fixed. + +### 1.3 Versioning + +- Frontend `frontend/package.json` `version: "0.0.0"`; daemon + `backend/internal/cli/version.go:12` `Version = "dev"`; `build-daemon.mjs` runs + `go build ./cmd/ao` with **no `-ldflags`**. No real semver anywhere. + +### 1.4 Signing / notarization / auto-update + +- `osxSign`/`osxNotarize` are gated on secrets (`forge.config.ts:24-40`) that are + **not set in CI**; the workflow header (`frontend-release.yml:13-15`) says builds + are **UNSIGNED**. +- **Auto-update is already wired**: `frontend/src/main.ts:14` imports + `updateElectronApp` from `update-electron-app`; `initAutoUpdates()` + (`main.ts:817`) runs it when `app.isPackaged`. Inert today because builds are + unsigned and version is `0.0.0` (its own comment, `main.ts:813-816`). + +### 1.5 `~/.ao` state and app lifecycle + +- Canonical home `~/.ao` (`backend/internal/config/config.go:296`, + `frontend/src/shared/daemon-discovery.ts:107`); overrides `AO_DATA_DIR`/`AO_RUN_FILE`. +- `userData` pinned to `~/.ao/electron` (`main.ts:64`, before `whenReady`; CLAUDE.md + hard rule). +- `~/.ao/running.json` is written by the **daemon** (`backend/internal/runfile/runfile.go` + `Write`, atomic temp+rename), read by the app (`daemon-discovery.ts parseRunFile`). + Only `running.json` exists in `~/.ao` today; **`app-state.json` does not exist yet**. +- App startup (`main.ts:822` `whenReady`): `registerRendererProtocol()` → + `createWindow()` → `void startDaemon()` → `initAutoUpdates()`. The app already + **spawns and owns the daemon** (`startDaemon`, spawns the bundled `ao daemon`). +- `app.moveToApplicationsFolder()` is **not used** anywhere (macOS-only). +- Login-shell env resolved at startup via `zsh -ilc '… env -0'` + (`frontend/src/shared/shell-env.ts:27`). + +### 1.6 npm delivery of the Go binary (the packaging gap) + +- The `ao` binary is `backend/cmd/ao` (`cmd/ao/main.go` → `cli.Execute()`); the + same binary serves as both the CLI and `ao daemon`. `build-daemon.mjs` builds it + to `frontend/daemon/ao` and bundles it into the desktop app. +- **This repo has no npm-registry publish path for the `ao` binary** (only + electron-forge → GitHub Releases; no `NPM_TOKEN`, no publish workflow — research + confirmed). The old AO npm package shipped `ao` via npm; that delivery mechanism + must be **ported/rebuilt here** (task T2). To honor "zero install scripts" + (npm v12, est. July 2026, blocks unapproved install scripts), the Go binary + should ship via **per-platform `optionalDependencies` packages** (the + esbuild/turbo model: a tiny JS `bin` shim execs the right prebuilt binary), not + via a `postinstall` download. + +### 1.7 The Go `ao` CLI surface (already wired) + +`backend/cmd/ao/main.go` → `backend/internal/cli`. Cobra root (`root.go:154-202`) +registers **all** of: `daemon` (hidden), **`start`**, `stop`, `status`, `doctor`, +`spawn`, `send`, `preview`, `hooks`, `launch`, `ptyhost`, `import`, `project`, +`session`, `orchestrator`, `review`, `completion`, `version`. These are real +(`doctor.go` is 20KB of health checks; `import.go` imports a legacy AO install). +The CLI is a thin client: commands "discover the local daemon, call its loopback +HTTP API, and format output" (`root.go:1-3`). + +**Current `ao start` (`start.go:54-119`):** starts the daemon (spawns `ao daemon`, +waits for ready) and runs a first-boot legacy import (`maybeFirstBootImport`, +`start.go:84`). **This entire behavior is being replaced** (§6). + +--- + +## 2. Decisions locked + +1. **Releases land on `AgentWrapper/agent-orchestrator`.** Fix the forge publisher + to match; the download URL uses it. +2. **`ao start` = fetch + open the desktop app.** It no longer starts the daemon; + the frontend owns the daemon. The current daemon-spawn logic in `start.go` is + removed. +3. **npm ships the Go `ao` binary**; existing users update in place. No JS launcher + package. +4. **Marker = `~/.ao/app-state.json`**, written only by the app, every launch. +5. **Scope = Track A only** (de-scope auto-update copy; Track B is separate). +6. **All three platforms; Windows installer is NSIS.** +7. **Two release targets, never conflated:** + - **Production:** GitHub `AgentWrapper/agent-orchestrator`; npm = the real + package name (legacy `ao`). Cutting a prod release is a deliberate, gated + step, never part of the dev/test loop. + - **Test/dev:** GitHub **`harshitsinghbhandari/agent-orchestrator`** (the fork); + npm scope **`@theharshitsingh/ao`**. All `ao start` download/open testing runs + against fork releases and the test npm scope. + The download repo and npm scope are **build-time overridable** (§6.3, §8) so a + test binary fetches from the fork and a prod binary from AgentWrapper, with no + code edit between them. + +--- + +## 3. Scope + +**In scope (Track A):** + +- Rewrite the Go **`ao start`** subcommand: `resolve → fetch → open` the desktop + app, then print a deprecation notice. (`backend/internal/cli/start.go`.) +- Decide the fate of `ao start`'s current first-boot legacy import (§6.4). +- **App-side:** write `~/.ao/app-state.json` every launch (app is sole writer); + own `moveToApplicationsFolder()` relocation (macOS). +- **Release wiring:** point forge publisher at `AgentWrapper/agent-orchestrator`, + add stable version-free asset names, finalize the draft (or Releases-API + fallback), add Linux to the matrix. +- **npm delivery** of the Go binary (port the old AO mechanism; zero install + scripts via optionalDeps platform packages). +- macOS / Windows (NSIS) / Linux (deb/rpm or AppImage) fetch+open paths. + +**Out of scope:** + +- Track B: real version stamping, making the wired `update-electron-app` updater + live, configuring signing/notarization CI secrets, any copy promising + auto-update. +- Renaming the Go module path off `aoagents` (separate, large, not needed here). +- The other CLI subcommands (already wired; untouched). + +--- + +## 4. Core invariants (load-bearing) + +1. **The npm package runs zero install scripts.** No `preinstall`/`install`/ + `postinstall`, no `binding.gyp`. Ship the Go binary via per-platform + `optionalDependencies` + a JS `bin` shim, not a `postinstall` download. +2. **Filesystem is the source of truth; `app-state.json` is a fast-path hint.** + Never trust its recorded path without `stat`-ing it. +3. **The app is the sole writer of `app-state.json`.** `ao start` is read-only with + respect to it. This is what makes the npm and website routes converge without an + orphaned second copy. +4. **The app owns relocation** (`moveToApplicationsFolder()`), and rewrites the + marker path afterward. `ao start` never moves the app. +5. **`ao start` is dumb about versions.** Decision is present-or-absent only; never + compares versions. Updating an installed app is the app's own updater's job. +6. **Resolution order is fixed:** marker path → `stat` → known-location scan → + fetch. Fetch only when both miss. +7. **Stable, version-free release asset names** so `ao start` uses a constant URL. + +--- + +## 5. The marker contract: `~/.ao/app-state.json` + +New file, **app-written**, mirroring the daemon's proven atomic write +(`backend/internal/runfile/runfile.go`: temp file in same dir → atomic rename). + +```json +{ + "schemaVersion": 1, + "appPath": "/Applications/Agent Orchestrator.app", + "version": "0.0.0", + "installedAt": "2026-06-26T10:00:00Z", + "lastReconciledAt": "2026-06-26T10:05:00Z", + "installSource": "npm-bootstrap" +} +``` + +| Field | Writer | Meaning | +| ------------------ | ------ | -------------------------------------------------------------------------------- | +| `schemaVersion` | app | Marker format version. | +| `appPath` | app | Bundle path as of the last launch. | +| `version` | app | `app.getVersion()`. For the tour/migration, NOT for `ao start` update decisions. | +| `installedAt` | app | First marker write. | +| `lastReconciledAt` | app | Last launch that touched the marker. | +| `installSource` | app | `npm-bootstrap` / `website` / `github` / `unknown`; set only on first creation. | + +**Ownership:** only the app writes it, on **every launch**, self-healing a +stale/missing marker no matter how the app arrived. `ao start` only reads it, only +after `stat`-ing the path. + +--- + +## 6. The `ao start` subcommand (Go) — the heart of this effort + +Rewrite `backend/internal/cli/start.go`. Remove the daemon-spawn path +(`startDaemon`, `waitForReady`); the frontend owns the daemon now. + +### 6.1 New algorithm + +``` +ao start: + app = resolveApp() # marker → stat → known-location scan + if app == "": + app = fetchApp() # download latest for this platform, place it + opened = openApp(app) # launch; pass --installed-via=npm-bootstrap + printDeprecationNotice() # the app owns any rich first-run tour + if !opened: printManualOpen(app) + return nil # never blocks/supervises the app +``` + +All of this is Go, in the `cli` package, reusing existing deps +(`Deps.CommandOutput`, `Deps.LookPath`, `Deps.Executable`) and the `~/.ao` +resolution already in `backend/internal/config`. + +### 6.2 `resolveApp()` (invariants 2, 5, 6) + +1. Read `~/.ao/app-state.json`; if `appPath` `stat`s as a usable bundle, return it. +2. Else scan known locations per platform (covers website installs / stale marker): + - macOS: `/Applications/Agent Orchestrator.app`, `~/Applications/…` + - Windows: `%LOCALAPPDATA%\Programs\agent-orchestrator\…`, `C:\Program Files\Agent Orchestrator\…` + - Linux: `/opt/Agent Orchestrator/…`, `~/.local/bin`, `/usr/bin` +3. Else return empty → caller fetches. Never compare versions. + +### 6.3 `fetchApp()` + `openApp()` — platform asymmetry (real design point) + +Constant URL: `https://github.com///releases/latest/download/` +(302 → asset; requires non-draft release + stable names, §8). `/` is +**build-time overridable**, not hardcoded: default `AgentWrapper/agent-orchestrator` +(prod), overridden to `harshitsinghbhandari/agent-orchestrator` for test builds via +a `-ldflags -X …cli.releaseRepo=/` injection (mirrors how the daemon +version will be stamped). So the dev loop fetches from the fork; prod fetches from +AgentWrapper, with no source edit. + +- **macOS:** download `.zip` → unpack with **`ditto -x -k`** (preserves the `.app` + signature; plain unzip corrupts it) → `open --args --installed-via=npm-bootstrap`. + The app relocates itself to `/Applications` on first launch. +- **Windows:** the asset is an **NSIS installer `.exe`** (not a runnable bundle). + `fetch` downloads it; `open` runs the installer (interactive, or `/S` silent), + then `resolveApp()` finds the installed exe and launches it. +- **Linux:** `.deb`/`.rpm` need privileged install, or switch the Linux artifact to + an **AppImage** (single executable, no install) — better fit for fetch-and-run + (decide §11). + +### 6.4 The legacy first-boot import + +`ao start` currently runs `maybeFirstBootImport` (`start.go:84`, imports a legacy +AO install before the daemon starts). With the daemon-spawn removed, this must +move. Options (decide §11): (a) the **desktop app** runs the import when it first +boots its daemon; (b) drop it from `ao start` and rely on the standalone `ao +import` command (still wired). Recommended: (a), so the on-ramp still migrates +existing data. + +### 6.5 Other subcommands / bare `ao` + +Unchanged — they stay wired and talk to the app-owned daemon's loopback API. Add a +one-line deprecation hint to the root long-help noting that npm is now an on-ramp +and the app is the home. Do **not** alter `stop`/`status`/`spawn`/etc. behavior. + +--- + +## 7. App-side responsibilities + +### 7.1 Marker write + relocation (new) + +Hook into `app.whenReady()` (`main.ts:822`), **before** `createWindow()`, ordered +**relocate → write marker** (the marker must record the post-relocation path): + +```ts +app.whenReady().then(async () => { + if (process.platform === "darwin" && app.isPackaged) { + try { + app.moveToApplicationsFolder(); + } catch { + /* declined / not movable */ + } + // success restarts the app, so code past here runs only if no move happened + } + await writeAppStateMarker(); // atomic temp+rename, mirror runfile.Write + registerRendererProtocol(); + createWindow(); + void startDaemon(); + initAutoUpdates(); +}); +``` + +`writeAppStateMarker()` records `app.getAppPath()`/`app.getVersion()` into +`~/.ao/app-state.json`. On first creation, capture `installSource` from the +`--installed-via` arg `ao start` passes (else `website`/`github`/`unknown`). + +### 7.2 Already done — rely on it + +Daemon ownership (`main.ts startDaemon` + the #2185 supervisor link, +`main/supervisor-link.ts`), login-shell env (`shell-env.ts:27`), and the `userData` +pin (`main.ts:64`) are in place. Do not re-implement. + +--- + +## 8. Release / build wiring + +- **Publisher repo is overridable** (`forge.config.ts:86`): default prod + `AgentWrapper/agent-orchestrator`, but read from an env var (e.g. + `AO_RELEASE_REPO`) so a fork build publishes to + `harshitsinghbhandari/agent-orchestrator`. The dev loop publishes a draft+finalize + release **on the fork** and points the test binary's `cli.releaseRepo` at the same + fork. Never publish to AgentWrapper from a test run. +- **Stable asset names:** add a release-workflow step renaming each maker output to + space-free names (`agent-orchestrator-darwin-arm64.zip`, + `agent-orchestrator-win32-x64.exe`, the Linux artifact per §11) before upload. +- **Finalize the draft:** flip `draft: false` or add a CI publish step; the constant + URL only resolves for a published release. +- **`.zip` for macOS** unpacked with `ditto`; do not switch to `.tar.gz`. +- **Linux in the matrix:** add `ubuntu-latest` (#2191). +- **One tag drives versions** once Track B lands. + +--- + +## 9. Track B prerequisites (NOT this effort; keeps v1 copy honest) + +The `update-electron-app` updater is wired (§1.4) but inert until **both**: real +version stamping (bump `package.json`; inject daemon version via `-ldflags -X +…cli.Version=` in `build-daemon.mjs`) **and** signed+notarized macOS builds +(`CSC_LINK` + `APPLE_*` in CI). Until then, v1 copy must **not** promise +auto-update; users self-update by re-running `ao start` or downloading from the +website. + +--- + +## 10. Acceptance criteria / test matrix + +| # | Scenario | Expected | +| --- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `npm i -g @theharshitsingh/ao` (test scope) | Zero `allow-scripts` warning; nothing listed by `npm approve-scripts --allow-scripts-pending`. | +| 2 | `npm i -g @theharshitsingh/ao --ignore-scripts` (v12 sim) | Install succeeds; `ao` runs; `ao start` works (binary delivered via optionalDeps, not a script). | +| 3 | Fresh macOS `ao start` | Fetches `.zip`, `ditto`-unpacks, opens `Agent Orchestrator.app`; app relocates to `/Applications`; `~/.ao/app-state.json` records the `/Applications` path. | +| 4 | Website install first, then `ao start` | Known-location scan finds it; opens; no second copy fetched. | +| 5 | App trashed (marker stale), then `ao start` | Marker `stat` misses → scan misses → re-fetch. | +| 6 | App relocated by the app | Marker path rewritten; next `ao start` opens the right path; no orphan. | +| 7 | Installed-but-old app, `ao start` | Opens it and exits; does NOT fetch a newer one. | +| 8 | Windows `ao start` | Downloads NSIS `.exe`, runs installer, resolves + opens installed exe. | +| 9 | Linux `ao start` | Fetches chosen artifact and launches. | +| 10 | `ao stop`/`ao status`/`ao spawn` after `ao start` | Work against the app-owned daemon (CLI is a client). | +| 11 | Existing CLI user runs `npm update` then `ao start` | New binary in place; `ao start` no longer starts a daemon, it opens the app; their `ao import` data migrates (per §6.4). | + +> `ao start` opens the app through the calling shell's enriched env, so a green +> `ao start` proves nothing about the Dock-launch path. Test the Dock path +> separately. + +--- + +## 11. Open decisions (decide before the affected task) + +1. **npm delivery mechanism** for the Go binary: per-platform `optionalDependencies` + packages (recommended, zero-install-script) vs porting whatever the old AO + package did. Test scope is **`@theharshitsingh/ao`**; the **prod package name** + (the legacy `ao` users already have) still needs confirming, plus an `NPM_TOKEN` + - publish workflow for each. +2. **Legacy first-boot import** (§6.4): move into the desktop app, or drop from + `ao start` and rely on `ao import`? +3. **Linux artifact form:** `.deb`/`.rpm` (install) vs **AppImage** (fetch-and-run). +4. **Draft release finalization:** `draft: false` vs a CI publish step. +5. **Signing gate:** gate the launcher on signed+notarized builds, ship against + unsigned (Gatekeeper/SmartScreen warnings), or treat signing as a parallel + effort meeting at release? +6. **Download integrity:** SHA256 vs HTTPS-only vs `codesign --verify`. +7. **First-run tour + `installSource`:** in-app tour now (no auto-update promise), + defer tour but keep `installSource`, or neither? +8. **Website URL** for the deprecation notice copy. +9. **Module-path rename** off `aoagents` — confirm out of scope for this effort. + +--- + +## 12. Task breakdown (for AO execution, dependency-ordered) + +**Batch 1 — wiring (parallel):** + +- **T1. Rewrite `ao start` core (Go).** Replace `start.go` daemon-spawn with + `resolveApp()` + the macOS fetch/open path + deprecation notice; remove + `waitForReady`/daemon logic; decide §11.2. Check: on a mac with the app present, + `ao start` opens it and writes nothing; with it absent, it fetches+opens. +- **T2. npm delivery of the Go binary.** Per §11.1: optionalDeps platform packages + - JS `bin` shim, zero install scripts; publish workflow. **Publish to the + `@theharshitsingh/ao` test scope**, not the prod package. Check: `npm i -g +@theharshitsingh/ao --ignore-scripts` yields a working `ao`. +- **T3. Release repo + asset wiring (override-driven).** Make the forge publisher + repo + the `ao start` download repo build-time overridable (§6.3, §8); add the + stable-asset rename step; finalize the draft (§11.4); add Linux to the matrix. + Check: a `workflow_dispatch` **on the fork** produces a published + `harshitsinghbhandari/agent-orchestrator` release whose + `releases/latest/download/` 302-resolves. **No prod (AgentWrapper) + release is cut during development.** + +**Batch 2 — app-side + macOS end-to-end (after T1):** + +- **T4. App-side marker + relocation** (`main.ts whenReady`, §7.1). Check: a + packaged launch writes/updates `~/.ao/app-state.json` with the real bundle path. +- **T5. macOS `ao start` end-to-end against the FORK release** (needs T3): build the + test `ao` with `cli.releaseRepo=harshitsinghbhandari/agent-orchestrator`, install + it from `@theharshitsingh/ao`, run `ao start`. Check: acceptance #3–#7 on a mac, + fetching from the fork. + +**Batch 3 — cross-platform + integrity (after T1/T3):** + +- **T6. Windows path** (NSIS fetch+install+resolve, §6.3). +- **T7. Linux path** (§11.3). +- **T8. Download integrity** (§11.6). + +**Batch 4 — rollout:** + +- **T9.** Deprecation notice / optional tour + `installSource` (§11.7); legacy + import placement (§11.2) if not done in T1. + +> Track B (version stamping, signing, making the updater live) is a separate +> effort. Any copy added above must not promise auto-update until it lands. diff --git a/docs/backend-code-structure.md b/docs/backend-code-structure.md index 1910cb2b7..745563190 100644 --- a/docs/backend-code-structure.md +++ b/docs/backend-code-structure.md @@ -134,12 +134,14 @@ graph TD ``` **Belongs here:** + - Shared IDs: `ProjectID`, `SessionID`, `IssueID` - Enums and status vocabulary - Durable fact records used across packages - PR, tracker, project, session vocabulary **Does NOT belong here:** + - HTTP request/response DTOs - CLI output shapes - OpenAPI wrapper types @@ -169,11 +171,13 @@ graph LR ``` **Belongs here:** + - Interfaces consumed by core packages, implemented by adapters - Capability structs: `RuntimeConfig`, `WorkspaceConfig`, `SpawnConfig` - Vocabulary at the boundary between core and adapters **Does NOT belong here:** + - Resource read models (belongs in `service/*`) - HTTP request/response DTOs (belongs in `httpd`) - sqlc rows (belongs in `storage/sqlite`) @@ -181,15 +185,15 @@ graph LR **Key Port Interfaces:** -| Port | Purpose | Implementations | -|------|---------|-----------------| -| `Runtime` | Process isolation | `tmux`, `conpty` | -| `Workspace` | Git worktree management | `gitworktree` | -| `Agent` | Agent launching | 23+ agent adapters | -| `SCM` | PR/CI observation | `github` | -| `Tracker` | Issue tracking | `github` (adapter only) | -| `AgentMessenger` | Agent communication | Agent hooks | -| `PRWriter` | PR persistence | `pr.Manager` | +| Port | Purpose | Implementations | +| ---------------- | ----------------------- | ----------------------- | +| `Runtime` | Process isolation | `tmux`, `conpty` | +| `Workspace` | Git worktree management | `gitworktree` | +| `Agent` | Agent launching | 23+ agent adapters | +| `SCM` | PR/CI observation | `github` | +| `Tracker` | Issue tracking | `github` (adapter only) | +| `AgentMessenger` | Agent communication | Agent hooks | +| `PRWriter` | PR persistence | `pr.Manager` | --- @@ -243,6 +247,7 @@ graph LR ``` **Belongs here:** + - Resource use cases called by HTTP controllers and CLI - Resource read models and command/result types - Display-model assembly (e.g., session status derivation) @@ -250,6 +255,7 @@ graph LR - Small store interfaces consumed by the service **Does NOT belong here:** + - Low-level runtime/workspace/agent process control - Raw sqlc generated rows as public results - HTTP routing, path parsing, status-code decisions @@ -284,12 +290,14 @@ graph TD ``` **Belongs here:** + - Multi-step session mutations with rollback - Resource sequencing (workspace → runtime → agent) - Resource teardown safety and cleanup - Internal errors: not found, terminated, not restorable **Does NOT belong here:** + - HTTP request decoding - CLI formatting - Controller-facing list/get read-model assembly @@ -340,11 +348,13 @@ graph LR ``` **Belongs here:** + - Updates to lifecycle-owned session facts - Guardrails around runtime/activity observations - Lifecycle-triggered agent nudges for actionable PR facts **Does NOT belong here:** + - Display status persistence (use service layer instead) - HTTP/CLI DTOs - Direct adapter implementation details @@ -385,15 +395,18 @@ graph TD ``` **Current observation packages:** + - `internal/observe/scm` — SCM (GitHub) observer loop - `internal/observe/reaper` — Runtime liveness observation loop **Belongs here:** + - Polling loops and observation logic - External state transformation into domain facts - Observation error handling and retry logic **Does NOT belong here:** + - Product workflow decisions (belongs in service layer) - Direct storage writes (use lifecycle instead) @@ -420,6 +433,7 @@ graph TD ``` **Belongs here:** + - Connection setup and PRAGMAs - Goose migrations - sqlc queries and generated code @@ -427,6 +441,7 @@ graph TD - Transactions and CDC-triggered persistence behavior **Does NOT belong here:** + - HTTP response types - CLI output formatting - Product display status rules @@ -454,11 +469,13 @@ graph LR ``` **Belongs here:** + - Event type definitions for the CDC stream - Poller and broadcaster logic - Subscriber fan-out behavior **Does NOT belong here:** + - Terminal byte streams (belongs in `internal/terminal`) - Product workflow decisions (belongs in service layer) - Database schema ownership (belongs in `storage/sqlite`) @@ -485,11 +502,13 @@ graph TD ``` **Belongs here:** + - Per-client attachment lifecycle - Input/output framing independent of HTTP - PTY-backed attach handling and terminal protocol tests **Does NOT belong here:** + - HTTP-specific concerns (belongs in `httpd`) - HTTP routing or WebSocket upgrade logic @@ -520,6 +539,7 @@ graph TD ``` **Belongs here:** + - Routing and middleware - HTTP request decoding and response encoding - Path/query parameter handling @@ -529,6 +549,7 @@ graph TD - WebSocket upgrade handling for terminal mux **Does NOT belong here:** + - Direct adapter or SQLite store access - Application read models shared with CLI (belongs in `service/*`) @@ -556,12 +577,14 @@ graph LR ``` **Belongs here:** + - Daemon discovery - HTTP API calls - Command output formatting - Process control: start/stop/status/doctor **Does NOT belong here:** + - Duplicate daemon business logic (put in daemon service/API) - Direct storage, runtime, or adapter access @@ -592,12 +615,14 @@ graph TD ``` **Adapter principles:** + - Adapters are leaves in the import graph - Adapters translate external behavior into AO ports/domain concepts - Adapters should not own product workflows - All adapter-written files must be gitignored **Good dependencies:** + ``` session_manager → ports.Runtime adapters/runtime/tmux → ports + domain @@ -606,6 +631,7 @@ daemon → adapters + services + storage ``` **Avoid:** + ``` domain → adapters service/session → adapters/runtime/tmux @@ -635,14 +661,16 @@ graph TD ``` **Belongs here:** + - Production dependency construction - Adapter registration - Startup/shutdown sequencing - Cross-component wiring **Does NOT belong here:** + - Business logic (belongs in service, lifecycle, or manager packages) -- Adapter implementation details (belongs in adapters/*) +- Adapter implementation details (belongs in adapters/\*) --- @@ -669,6 +697,7 @@ graph LR ``` **Key environment variables:** + - `AO_PORT` — HTTP bind port (default: 3001) - `AO_REQUEST_TIMEOUT` — Per-request timeout (default: 60s) - `AO_SHUTDOWN_TIMEOUT` — Graceful shutdown cap (default: 10s) @@ -769,6 +798,7 @@ graph TD ``` **Key patterns:** + - All arrows point downward (no cycles) - Adapters and domain are leaves - CLI and HTTPD don't touch storage directly @@ -790,6 +820,7 @@ flowchart LR ``` **Steps:** + 1. Add controller in `httpd/controllers/` 2. Call a `service/*` package 3. Update OpenAPI generation @@ -808,6 +839,7 @@ flowchart TD ``` **Steps:** + 1. Add shared IDs/vocabulary to `domain` 2. Create use cases in `service/` 3. Add storage in `storage/sqlite` @@ -828,6 +860,7 @@ flowchart LR ``` **Steps:** + 1. Implement a `ports` interface under `adapters//` 2. For agents: implement hooks with gitignored files 3. Wire in `daemon` @@ -929,12 +962,14 @@ func (s *Service) Create(ctx context.Context, cfg Config) (MyResource, error) { 6. **Daemon** wires it all — composition root **Always ask:** + - Does this belong in domain (shared concept)? - Does this belong in ports (shared capability)? - Does this belong in service (use case)? - Does this belong in adapters (external system)? **Never:** + - Put HTTP types in domain - Put display status in storage - Put business logic in CLI diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index 99c3ab70f..39ae77de5 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -2,6 +2,22 @@ import type { ForgeConfig } from "@electron-forge/shared-types"; import { VitePlugin } from "@electron-forge/plugin-vite"; import MakerNSIS from "./makers/maker-nsis"; +// Default GitHub release target (production). aoagents was the temporary rewrite +// home; releases land on AgentWrapper (spec §1.1). +const DEFAULT_RELEASE_REPO = "AgentWrapper/agent-orchestrator"; + +// parseReleaseRepo turns an "owner/repo" string (from AO_RELEASE_REPO) into the +// publisher-github { owner, name } shape, falling back to the production default +// when unset or malformed. +function parseReleaseRepo(value: string | undefined): { owner: string; name: string } { + const [owner, name] = (value || DEFAULT_RELEASE_REPO).split("/"); + if (!owner || !name) { + const [defOwner, defName] = DEFAULT_RELEASE_REPO.split("/"); + return { owner: defOwner, name: defName }; + } + return { owner, name }; +} + const config: ForgeConfig = { packagerConfig: { asar: true, @@ -82,10 +98,22 @@ const config: ForgeConfig = { publishers: [ { name: "@electron-forge/publisher-github", + // Release target is build-time overridable so a fork run publishes to the + // fork without a source edit. AO_RELEASE_REPO is "owner/repo"; it defaults + // to the production target. The dev/test loop sets + // AO_RELEASE_REPO=harshitsinghbhandari/agent-orchestrator (spec §1.1, §8). + // Note: aoagents/agent-orchestrator was the temporary rewrite home and is + // intentionally NOT the default; releases land on AgentWrapper. config: { - repository: { owner: "aoagents", name: "agent-orchestrator" }, + repository: parseReleaseRepo(process.env.AO_RELEASE_REPO), prerelease: false, - draft: true, + // draft:false so the release is immediately live, which is what the + // `ao start` bootstrapper's constant + // releases/latest/download/ URL needs to 302-resolve. A draft + // release 404s on that URL (spec §2.7, §8, §11.4). Prod may later + // switch to a draft + manual-finalize flow; for now the bootstrapper + // needs a published release. + draft: false, }, }, ], diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0c00e2a0c..c77f9ac1e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -35,6 +35,7 @@ import { buildTelemetryBootstrap } from "./shared/telemetry"; import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host"; import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link"; import { shouldLinkOnAttach } from "./main/daemon-owner"; +import { writeAppStateMarker } from "./main/app-state"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -819,7 +820,86 @@ function initAutoUpdates(): void { updateElectronApp(); } -app.whenReady().then(() => { +// Resolve the bundle path `ao start` will later `open` and stat as a usable app. +// On macOS process.execPath is .../Agent Orchestrator.app/Contents/MacOS/; +// the thing `ao start` opens is the enclosing `.app` directory, so walk up three +// levels (MacOS -> Contents -> .app). app.getAppPath() is WRONG here: it returns +// the app.asar archive path inside the bundle, not the bundle itself. +// On win32/linux there is no .app wrapper, so record execPath; a richer +// resolveApp() for those platforms lands in T6/T7. +function resolveBundlePath(): string { + if (process.platform === "darwin") { + return path.resolve(process.execPath, "..", "..", ".."); + } + return process.execPath; +} + +// `ao start` opens the app with `--installed-via=` so the app can record +// how it arrived on first marker creation. Parse it out of argv; absent => the +// marker defaults installSource to "unknown". +function parseInstalledVia(argv: string[]): string | undefined { + const flag = argv.find((a) => a.startsWith("--installed-via=")); + return flag ? flag.slice("--installed-via=".length) : undefined; +} + +// Write ~/.ao/app-state.json so `ao start`'s resolveApp() can find this bundle +// (spec §7.1). The app is the sole writer (invariant 3) and writes every launch. +// A failure here must NOT block startup, so the caller wraps this in try/catch; +// we still surface it via the log. +async function writeAppStateOnLaunch(): Promise { + // Reuse the same ~/.ao resolution as running.json; the marker lives beside it + // (the Go side computes its dir as dirname(RunFilePath)). runFilePath() returns + // null only when the home dir is unresolvable, in which case we cannot place + // the marker; the caller's try/catch logs it. + const runFile = runFilePath(); + if (!runFile) { + throw new Error("cannot resolve ~/.ao run-file path; skipping app-state marker"); + } + const stateDir = path.dirname(runFile); + await writeAppStateMarker({ + stateDir, + appPath: resolveBundlePath(), + version: app.getVersion(), + installedVia: parseInstalledVia(process.argv), + now: () => new Date(), + }); +} + +app.whenReady().then(async () => { + // Capture install provenance BEFORE relocation. moveToApplicationsFolder() + // relaunches from /Applications WITHOUT forwarding our --installed-via arg, and + // code past a successful move never runs in this instance, so a post-move-only + // write would record installSource="unknown" and the sticky logic in + // writeAppStateMarker would then lock it there forever. Writing now (only when + // the arg is present, i.e. the npm-bootstrap launch) persists the source so the + // post-move instance preserves it while refreshing appPath to /Applications. + if (parseInstalledVia(process.argv)) { + try { + await writeAppStateOnLaunch(); + } catch (err) { + console.error("failed to write pre-relocation app-state marker:", err); + } + } + + if (process.platform === "darwin" && app.isPackaged) { + try { + // On success this restarts the app from /Applications, so code past + // here only runs when no move happened (already there, or declined). + app.moveToApplicationsFolder(); + } catch (err) { + console.error("relocation to Applications failed:", err); + } + } + + // Refresh the marker post-relocation so appPath records the final bundle path; + // the sticky installSource preserves the value captured above. A marker-write + // failure is non-fatal: log and continue so the app still boots. + try { + await writeAppStateOnLaunch(); + } catch (err) { + console.error("failed to write app-state marker:", err); + } + registerRendererProtocol(); createWindow(); void startDaemon(); diff --git a/frontend/src/main/app-state.test.ts b/frontend/src/main/app-state.test.ts new file mode 100644 index 000000000..5b6b6b018 --- /dev/null +++ b/frontend/src/main/app-state.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { APP_STATE_FILE_NAME, writeAppStateMarker, type AppStateMarker } from "./app-state"; + +// The exact key set the Go reader (start.go `appState`) unmarshals. +const GO_READER_KEYS = ["schemaVersion", "appPath", "version", "installedAt", "lastReconciledAt", "installSource"]; + +async function readMarker(dir: string): Promise { + const raw = await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8"); + return JSON.parse(raw) as AppStateMarker; +} + +describe("writeAppStateMarker", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "ao-app-state-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("first write sets installedAt + installSource from installedVia", async () => { + const t = new Date("2026-06-26T10:00:00.000Z"); + await writeAppStateMarker({ + stateDir: dir, + appPath: "/Applications/Agent Orchestrator.app", + version: "0.0.0", + installedVia: "npm-bootstrap", + now: () => t, + }); + + const m = await readMarker(dir); + expect(m.schemaVersion).toBe(1); + expect(m.appPath).toBe("/Applications/Agent Orchestrator.app"); + expect(m.version).toBe("0.0.0"); + expect(m.installedAt).toBe("2026-06-26T10:00:00.000Z"); + expect(m.lastReconciledAt).toBe("2026-06-26T10:00:00.000Z"); + expect(m.installSource).toBe("npm-bootstrap"); + }); + + it("second write PRESERVES installedAt/installSource and updates appPath/version/lastReconciledAt", async () => { + await writeAppStateMarker({ + stateDir: dir, + appPath: "/tmp/staging/Agent Orchestrator.app", + version: "0.0.0", + installedVia: "npm-bootstrap", + now: () => new Date("2026-06-26T10:00:00.000Z"), + }); + + // Second launch: app relocated, version bumped, different install arg. + await writeAppStateMarker({ + stateDir: dir, + appPath: "/Applications/Agent Orchestrator.app", + version: "1.2.3", + installedVia: "github", + now: () => new Date("2026-06-26T11:30:00.000Z"), + }); + + const m = await readMarker(dir); + // Preserved from first creation. + expect(m.installedAt).toBe("2026-06-26T10:00:00.000Z"); + expect(m.installSource).toBe("npm-bootstrap"); + // Refreshed. + expect(m.appPath).toBe("/Applications/Agent Orchestrator.app"); + expect(m.version).toBe("1.2.3"); + expect(m.lastReconciledAt).toBe("2026-06-26T11:30:00.000Z"); + }); + + it("written JSON keys exactly match the Go reader struct", async () => { + await writeAppStateMarker({ + stateDir: dir, + appPath: "/Applications/Agent Orchestrator.app", + version: "0.0.0", + installedVia: "npm-bootstrap", + now: () => new Date("2026-06-26T10:00:00.000Z"), + }); + + const raw = await readFile(path.join(dir, APP_STATE_FILE_NAME), "utf8"); + const keys = Object.keys(JSON.parse(raw) as Record); + expect(keys.sort()).toEqual([...GO_READER_KEYS].sort()); + // Trailing newline, mirroring runfile.Write. + expect(raw.endsWith("}\n")).toBe(true); + }); + + it("installedVia undefined => installSource 'unknown'", async () => { + await writeAppStateMarker({ + stateDir: dir, + appPath: "/Applications/Agent Orchestrator.app", + version: "0.0.0", + now: () => new Date("2026-06-26T10:00:00.000Z"), + }); + + const m = await readMarker(dir); + expect(m.installSource).toBe("unknown"); + }); + + it("atomic write leaves no temp file behind", async () => { + await writeAppStateMarker({ + stateDir: dir, + appPath: "/Applications/Agent Orchestrator.app", + version: "0.0.0", + installedVia: "npm-bootstrap", + now: () => new Date("2026-06-26T10:00:00.000Z"), + }); + + const entries = await readdir(dir); + expect(entries).toEqual([APP_STATE_FILE_NAME]); + expect(entries.some((e) => e.startsWith(".app-state-"))).toBe(false); + }); + + it("creates the state dir when it does not exist", async () => { + const nested = path.join(dir, "does", "not", "exist"); + await writeAppStateMarker({ + stateDir: nested, + appPath: "/Applications/Agent Orchestrator.app", + version: "0.0.0", + now: () => new Date("2026-06-26T10:00:00.000Z"), + }); + + const m = await readMarker(nested); + expect(m.appPath).toBe("/Applications/Agent Orchestrator.app"); + }); +}); diff --git a/frontend/src/main/app-state.ts b/frontend/src/main/app-state.ts new file mode 100644 index 000000000..ab35a26c2 --- /dev/null +++ b/frontend/src/main/app-state.ts @@ -0,0 +1,99 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * The marker the desktop app writes under ~/.ao on every launch (spec §5). + * It is the fast-path hint `ao start` reads to locate the installed bundle. + * The Go reader is backend/internal/cli/start.go `appState`; the JSON keys + * below MUST match its struct tags exactly (camelCase). + */ +export interface AppStateMarker { + schemaVersion: number; + appPath: string; + version: string; + installedAt: string; + lastReconciledAt: string; + installSource: string; +} + +/** Current marker format version (spec §5, schemaVersion field). */ +const SCHEMA_VERSION = 1; + +/** File name of the marker under the ~/.ao state dir. */ +export const APP_STATE_FILE_NAME = "app-state.json"; + +export interface WriteAppStateOptions { + /** Directory the marker lives in (dirname of running.json, i.e. ~/.ao). */ + stateDir: string; + /** Bundle path as of this launch (the macOS .app, or the platform exe). */ + appPath: string; + /** app.getVersion(). */ + version: string; + /** + * How the app was installed, captured ONLY on first marker creation from + * `ao start`'s --installed-via arg. Subsequent launches preserve the value + * already on disk. Defaults to "unknown" when absent on first creation. + */ + installedVia?: string; + /** Injectable clock so tests can assert deterministic timestamps. */ + now: () => Date; +} + +/** + * Read a marker already on disk, tolerating a missing/garbage file. Returns + * null when the file is absent or unparseable so the caller treats this as a + * first creation (self-healing, spec §5 "self-healing a stale/missing marker"). + */ +async function readExisting(file: string): Promise { + let raw: string; + try { + raw = await readFile(file, "utf8"); + } catch { + return null; + } + try { + return JSON.parse(raw) as AppStateMarker; + } catch { + return null; + } +} + +/** + * Write ~/.ao/app-state.json. The app is the SOLE writer (invariant 3) and + * writes on every launch. Mirrors the daemon's proven atomic write + * (backend/internal/runfile/runfile.go Write): a temp file in the same dir + * then an atomic rename, so a concurrent `ao start` reader never observes a + * partial file. + * + * On first creation, installedAt and installSource are captured and then + * preserved across all later launches; appPath, version, and lastReconciledAt + * are refreshed every launch (spec §5 field table). + */ +export async function writeAppStateMarker(opts: WriteAppStateOptions): Promise { + // 0o750: owner rwx, group r-x, world none — matches runfile.Write's dir mode. + await mkdir(opts.stateDir, { recursive: true, mode: 0o750 }); + + const file = path.join(opts.stateDir, APP_STATE_FILE_NAME); + const existing = await readExisting(file); + const nowIso = opts.now().toISOString(); + + const marker: AppStateMarker = { + schemaVersion: SCHEMA_VERSION, + appPath: opts.appPath, + version: opts.version, + // Set once on first creation; preserve thereafter. + installedAt: existing?.installedAt ?? nowIso, + // Refreshed on every launch that touches the marker. + lastReconciledAt: nowIso, + installSource: existing?.installSource ?? opts.installedVia ?? "unknown", + }; + + // Pretty-print + trailing newline to match runfile.Write's MarshalIndent style. + const data = `${JSON.stringify(marker, null, 2)}\n`; + + // Temp file in the SAME dir so the rename is atomic (same filesystem). The + // random suffix avoids a collision if two launches race. + const tmp = path.join(opts.stateDir, `.app-state-${process.pid}-${Date.now()}.json`); + await writeFile(tmp, data, { mode: 0o600 }); + await rename(tmp, file); +} diff --git a/packages/.gitignore b/packages/.gitignore new file mode 100644 index 000000000..cd4c2388a --- /dev/null +++ b/packages/.gitignore @@ -0,0 +1,10 @@ +# Prebuilt Go binaries (~20MB each) produced by build-binaries.sh and shipped +# in each npm tarball via the package's `files` entry. Never committed. +# +# The repo-root .gitignore has a bare `bin/` rule that would otherwise ignore +# this whole directory tree (including the JS shim). Re-include the dirs and the +# shim, then ignore only the compiled binaries. +!*/bin/ +!ao/bin/ao.js +*/bin/ao +*/bin/ao.exe diff --git a/packages/ao-darwin-arm64/package.json b/packages/ao-darwin-arm64/package.json new file mode 100644 index 000000000..e95f20c2d --- /dev/null +++ b/packages/ao-darwin-arm64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@aoagents/ao-darwin-arm64", + "version": "0.10.0", + "description": "Agent Orchestrator CLI binary for macOS arm64.", + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "bin/ao" + ] +} diff --git a/packages/ao-darwin-x64/package.json b/packages/ao-darwin-x64/package.json new file mode 100644 index 000000000..51a94e2ad --- /dev/null +++ b/packages/ao-darwin-x64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@aoagents/ao-darwin-x64", + "version": "0.10.0", + "description": "Agent Orchestrator CLI binary for macOS x64.", + "license": "MIT", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/ao" + ] +} diff --git a/packages/ao-linux-x64/package.json b/packages/ao-linux-x64/package.json new file mode 100644 index 000000000..574a92c89 --- /dev/null +++ b/packages/ao-linux-x64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@aoagents/ao-linux-x64", + "version": "0.10.0", + "description": "Agent Orchestrator CLI binary for Linux x64.", + "license": "MIT", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/ao" + ] +} diff --git a/packages/ao-win32-x64/package.json b/packages/ao-win32-x64/package.json new file mode 100644 index 000000000..8a4b5c129 --- /dev/null +++ b/packages/ao-win32-x64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@aoagents/ao-win32-x64", + "version": "0.10.0", + "description": "Agent Orchestrator CLI binary for Windows x64.", + "license": "MIT", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/ao.exe" + ] +} diff --git a/packages/ao/bin/ao.js b/packages/ao/bin/ao.js new file mode 100755 index 000000000..d9b163d59 --- /dev/null +++ b/packages/ao/bin/ao.js @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Pure-Node shim: resolve the per-platform optionalDependency that holds the +// prebuilt Go `ao` binary for this host, then exec it transparently. +// Zero install scripts; zero third-party deps. The binary is delivered by npm +// installing only the matching `@aoagents/ao--` package (its +// os/cpu fields gate the rest out). + +"use strict"; + +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +// npm cpu names match process.arch (x64/arm64); npm os names match +// process.platform (darwin/win32/linux). Our platform packages are named +// `@aoagents/ao--` to mirror that exactly. +const platform = process.platform; +const arch = process.arch; +const pkg = `@aoagents/ao-${platform}-${arch}`; +const binName = platform === "win32" ? "ao.exe" : "ao"; + +function resolveBinary() { + // require.resolve the platform package's package.json to find its install + // dir (works whether hoisted to a parent node_modules or nested), then join + // the binary path. The platform package ships the binary under bin/. + let pkgJsonPath; + try { + pkgJsonPath = require.resolve(`${pkg}/package.json`); + } catch { + return null; + } + return path.join(path.dirname(pkgJsonPath), "bin", binName); +} + +const binary = resolveBinary(); + +if (!binary) { + process.stderr.write( + `@aoagents/ao: no prebuilt binary for ${platform}-${arch}.\n` + + `The optional dependency ${pkg} is not installed, which usually means\n` + + `this platform is unsupported. Supported: darwin-arm64, darwin-x64,\n` + + `win32-x64, linux-x64.\n`, + ); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }); + +if (result.error) { + if (result.error.code === "ENOENT") { + process.stderr.write( + `@aoagents/ao: binary not found at ${binary}.\n` + + `Reinstall @aoagents/ao to restore the platform package.\n`, + ); + } else { + process.stderr.write(`@aoagents/ao: failed to run binary: ${result.error.message}\n`); + } + process.exit(1); +} + +// Propagate signal-terminations as a conventional 128+signal code, else the +// child's own exit code. +if (result.signal) { + process.exit(1); +} +process.exit(result.status === null ? 1 : result.status); diff --git a/packages/ao/package.json b/packages/ao/package.json new file mode 100644 index 000000000..f28939fed --- /dev/null +++ b/packages/ao/package.json @@ -0,0 +1,18 @@ +{ + "name": "@aoagents/ao", + "version": "0.10.0", + "description": "Agent Orchestrator CLI. Resolves and runs the prebuilt Go binary for your platform.", + "license": "MIT", + "bin": { + "ao": "./bin/ao.js" + }, + "files": [ + "bin/" + ], + "optionalDependencies": { + "@aoagents/ao-darwin-arm64": "0.10.0", + "@aoagents/ao-darwin-x64": "0.10.0", + "@aoagents/ao-linux-x64": "0.10.0", + "@aoagents/ao-win32-x64": "0.10.0" + } +} diff --git a/packages/build-binaries.sh b/packages/build-binaries.sh new file mode 100755 index 000000000..7380b576e --- /dev/null +++ b/packages/build-binaries.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Cross-compile the Go `ao` binary (backend/cmd/ao) for every supported +# platform and drop each into the matching platform package's bin/ dir. +# +# Run this from any cwd before `npm publish`. It is the ONLY way the binaries +# get into the platform packages; they are gitignored and produced here, then +# shipped in each npm tarball via that package's `files` entry. +# +# CGO-free build (modernc.org/sqlite driver) so cross-compilation needs no C +# toolchain. Prod build: no -ldflags, so cli.releaseRepo keeps its default +# (AgentWrapper/agent-orchestrator). +set -euo pipefail + +# Repo layout: this script lives at /packages/build-binaries.sh. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +BACKEND_DIR="${REPO_ROOT}/backend" + +# pkg_dir : npm_os : npm_arch : GOOS : GOARCH : bin_name +TARGETS=( + "ao-darwin-arm64:darwin:arm64:darwin:arm64:ao" + "ao-darwin-x64:darwin:x64:darwin:amd64:ao" + "ao-win32-x64:win32:x64:windows:amd64:ao.exe" + "ao-linux-x64:linux:x64:linux:amd64:ao" +) + +echo "Building ao binaries from ${BACKEND_DIR}/cmd/ao" +for t in "${TARGETS[@]}"; do + IFS=":" read -r pkg npm_os npm_arch goos goarch bin <<<"$t" + out="${SCRIPT_DIR}/${pkg}/bin/${bin}" + mkdir -p "${SCRIPT_DIR}/${pkg}/bin" + echo " -> ${pkg} (GOOS=${goos} GOARCH=${goarch}) -> bin/${bin}" + (cd "${BACKEND_DIR}" && CGO_ENABLED=0 GOOS="${goos}" GOARCH="${goarch}" \ + go build -o "${out}" ./cmd/ao) + chmod 0755 "${out}" +done + +echo "Done. Built binaries:" +for t in "${TARGETS[@]}"; do + IFS=":" read -r pkg _ _ _ _ bin <<<"$t" + file "${SCRIPT_DIR}/${pkg}/bin/${bin}" +done