feat(cli): ao start fetches + opens the desktop app; deprecate npm as an app channel (Track A) (#2201)
* docs: grounded spec for ao start bootstrapper + npm deprecation Real-codebase-verified implementation spec (Track A: launcher + app-state marker + release asset wiring). Replaces the somthing.md draft's aspirational assumptions with file:line ground truth: correct bundle name (Agent Orchestrator.app), publish repo (aoagents/agent-orchestrator), draft-release + asset-rename gaps, unsigned-build reality, and the already-wired update-electron-app updater. Includes a dependency-ordered task breakdown for AO execution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: reframe spec around Go ao start subcommand (not a JS launcher) The npm package ships the existing Go cobra CLI (backend/cmd/ao); this effort rewrites the `ao start` subcommand to fetch+open the desktop app. Corrections: - releases land on AgentWrapper/agent-orchestrator (aoagents was the temporary rewrite home; forge publisher must be repointed) - ao start stops starting the daemon; the frontend owns the daemon - adds the real Go CLI command surface (1.7), the npm-delivery gap for the Go binary (1.6), and the legacy first-boot import decision (6.4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: separate prod vs test release targets in ao start spec Dev/test loop must never cut a production release. Download repo + forge publisher + npm scope are now build-time overridable: - prod: AgentWrapper/agent-orchestrator + real package name - test: harshitsinghbhandari/agent-orchestrator (fork) + @theharshitsingh/ao T3/T5 now release+test against the fork and the test scope, with prod cut as a separate gated step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli): rewrite ao start to fetch + open the desktop app (T1) ao start no longer starts the daemon (the desktop app owns it). It now resolves the installed app (~/.ao/app-state.json marker -> stat -> known- location scan), fetches the latest release zip and ditto-unpacks it on macOS when absent, opens it with --installed-via=npm-bootstrap, and prints an honest deprecation notice. releaseRepo is build-time overridable (-ldflags) so test builds fetch from the fork. Windows/Linux fetch/open are stubbed for T6/T7. Review fixes folded in: download() copies deps.HTTPClient and drops its 2s loopback-probe timeout (a real release asset is hundreds of MB), and fetchApp clears ~/.ao/staging before unpacking. Regression test covers the timeout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(desktop): write ~/.ao/app-state.json marker on launch (T4) The app is the sole writer of the marker ao start reads to locate the bundle. New frontend/src/main/app-state.ts does an atomic temp+rename write mirroring the daemon's runfile.Write, preserving installedAt/installSource across launches and refreshing appPath/version/lastReconciledAt. main.ts hooks it into app.whenReady ordered relocate (macOS) -> write marker -> createWindow, both non-fatal. Bundle path is derived from process.execPath (not app.getAppPath, which is the asar path); JSON keys match start.go's appState reader exactly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(desktop-release): overridable repo, published release, stable asset aliases (T3) Make the bootstrapper's constant releases/latest/download URL resolve: - forge publisher repo reads AO_RELEASE_REPO (default AgentWrapper/agent- orchestrator); the workflow sets it to github.repository so a fork run publishes to the fork and never to prod - draft:false so the release is immediately live (constant URL needs it) - add ubuntu-latest to the matrix (issue #2191) - post-publish steps upload stable space-free aliases (agent-orchestrator-darwin-arm64.zip, agent-orchestrator-win32-x64.exe) matching exactly what start.go fetches Review fix: the alias upload targeted GITHUB_REF_NAME (the git tag), but publisher-github creates the release as v<package.json version>; retargeted to that and relaxed the guard so workflow_dispatch also produces aliases. Known gaps (documented inline): macOS x64 needs an Intel runner (macos-latest is arm64-only); the Linux stable asset name awaits the deb/rpm-vs-AppImage decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] * feat(npm): deliver Go ao binary as @aoagents/ao via per-platform packages (T2) Ship the ao CLI to npm with ZERO install scripts using the esbuild model: - packages/ao: pure-JS @aoagents/ao with a bin/ao.js shim that resolves and execs the matching @aoagents/ao-<platform>-<arch> optionalDependency - four platform packages (darwin-arm64/x64, win32-x64, linux-x64), each os/cpu gated so npm installs only the host's; binary cross-compiled CGO-free (modernc.org/sqlite), gitignored, shipped via files - build-binaries.sh cross-compiles all four; releaseRepo keeps its prod default Not added as root workspaces on purpose: os/cpu-restricted members make root `npm ci` fail EBADPLATFORM (CI's api-drift job runs it). Packages publish standalone; the shim was verified in a published-like layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(npm): set @aoagents/ao packages to 0.10.0 (above existing 0.9.5) The existing @aoagents/ao on npm is 0.9.5; the launcher must publish a higher version so npm latest advances and existing users get the new fetch-and-open binary on update. All five packages plus the four optionalDeps pins set to 0.10.0. * chore: format with prettier [skip ci] * fix(start): capture install provenance before macOS relocation moveToApplicationsFolder() relaunches the app from /Applications without forwarding the --installed-via arg, and code past a successful move never runs in the staging instance. The post-move instance therefore wrote installSource="unknown", and writeAppStateMarker's sticky logic then locked it there, losing the npm-bootstrap provenance in the exact path it exists for. Write the marker before relocation when --installed-via is present so the source is persisted while the arg is still available; the post-move launch preserves it (sticky installSource) while refreshing appPath to /Applications. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
acd811dce3
commit
514946fd8b
|
|
@ -1,18 +1,32 @@
|
||||||
name: Desktop release
|
name: Desktop release
|
||||||
|
|
||||||
# Builds and publishes the Electron desktop app via electron-forge.
|
# Builds and publishes the Electron desktop app via electron-forge.
|
||||||
# Generates a GitHub Release (draft) with installers + update manifests.
|
# Generates a GitHub Release with installers + update manifests, then uploads
|
||||||
# Triggered by a `desktop-v*` tag or manually.
|
# stable, version-free asset aliases so `ao start`'s constant
|
||||||
|
# releases/latest/download/<name> URL resolves (spec §8, §11.4).
|
||||||
#
|
#
|
||||||
# Each target OS builds on its own runner so the bundled `ao` daemon is compiled
|
# 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
|
# 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
|
# 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
|
# 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
|
# ⚠️ Until macOS code signing + notarization secrets are configured (see
|
||||||
# frontend/docs/desktop-release.md), published builds are UNSIGNED and will
|
# frontend/docs/desktop-release.md), published builds are UNSIGNED and will
|
||||||
# NOT auto-update on macOS. The workflow still produces installable artifacts.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -25,7 +39,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest, windows-latest]
|
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
@ -50,6 +64,10 @@ jobs:
|
||||||
run: npm run publish
|
run: npm run publish
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
# macOS signing + notarization — add as repository secrets and
|
||||||
# set osxSign/osxNotarize in forge.config.ts to enable.
|
# set osxSign/osxNotarize in forge.config.ts to enable.
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||||
|
|
@ -57,3 +75,57 @@ jobs:
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
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/<name> 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<version>
|
||||||
|
# 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<package.json
|
||||||
|
# version> (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/<arch>/<dir-basename>-<version>.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<version> (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/<productName> Setup <version>.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.
|
||||||
|
|
|
||||||
74
README.md
74
README.md
|
|
@ -55,17 +55,17 @@
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
| Feature | Description |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **Platform-Native Runtimes** | tmux on Darwin/Linux, conpty on Windows for optimal performance |
|
||||||
| **Live PR Observation** | Provider-neutral SCM observer with automatic feedback routing |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **CDC Broadcasting** | DB triggers append changes to change_log, broadcasted via SSE |
|
||||||
| **Desktop Experience** | Native Electron app with React UI and live terminal streaming |
|
| **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 |
|
| **Loopback-Only Daemon** | HTTP control over 127.0.0.1 with no auth, CORS, or TLS by design |
|
||||||
|
|
||||||
### Supported Agents
|
### Supported Agents
|
||||||
|
|
||||||
|
|
@ -80,13 +80,14 @@ Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Curs
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
| Requirement | Minimum | Recommended |
|
| Requirement | Minimum | Recommended |
|
||||||
|-------------|---------|-------------|
|
| ----------- | ------- | ----------- |
|
||||||
| Go | 1.25+ | Latest |
|
| Go | 1.25+ | Latest |
|
||||||
| Node.js | 20+ | Latest LTS |
|
| Node.js | 20+ | Latest LTS |
|
||||||
| Git | Any | Latest |
|
| Git | Any | Latest |
|
||||||
| pnpm | Any | Latest |
|
| pnpm | Any | Latest |
|
||||||
|
|
||||||
**Optional:**
|
**Optional:**
|
||||||
|
|
||||||
- `tmux` (Darwin/Linux) - For Unix runtime
|
- `tmux` (Darwin/Linux) - For Unix runtime
|
||||||
- `gh` (GitHub CLI) - For authenticated GitHub API calls
|
- `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:
|
Download the latest release for your platform:
|
||||||
|
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
|----------|----------|
|
| ----------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
| **Windows** | [Setup.exe](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||||
| **macOS** | [Agent Orchestrator.dmg](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) |
|
| **Linux** | [Agent Orchestrator.AppImage](https://github.com/AgentWrapper/agent-orchestrator/releases/latest) |
|
||||||
|
|
||||||
**Direct Download:** [Latest Release](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
|
**Core mental model:** OBSERVE external facts → UPDATE durable facts → DERIVE display status / ACT
|
||||||
|
|
||||||
**Key components:**
|
**Key components:**
|
||||||
|
|
||||||
- **Frontend** - Electron + React UI with TanStack Router/Query and shadcn/ui
|
- **Frontend** - Electron + React UI with TanStack Router/Query and shadcn/ui
|
||||||
- **Backend Daemon** - Go-based HTTP server with controllers, services, and adapters
|
- **Backend Daemon** - Go-based HTTP server with controllers, services, and adapters
|
||||||
- **Runtime** - Platform-specific: `tmux` on Darwin/Linux, `conpty` on Windows
|
- **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
|
## Documentation
|
||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
| -------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules |
|
| [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 |
|
| [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules |
|
||||||
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
| [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract |
|
||||||
| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior |
|
| [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.
|
All configuration is environment-driven. The daemon takes no config file.
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|----------|---------|---------|
|
| --------------------- | -------------------- | --------------------------- |
|
||||||
| `AO_PORT` | `3001` | HTTP bind port |
|
| `AO_PORT` | `3001` | HTTP bind port |
|
||||||
| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout |
|
| `AO_REQUEST_TIMEOUT` | `60s` | Per-request timeout |
|
||||||
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap |
|
| `AO_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown cap |
|
||||||
| `AO_RUN_FILE` | `~/.ao/running.json` | PID/port handshake |
|
| `AO_RUN_FILE` | `~/.ao/running.json` | PID/port handshake |
|
||||||
| `AO_DATA_DIR` | `~/.ao/data` | SQLite data directory |
|
| `AO_DATA_DIR` | `~/.ao/data` | SQLite data directory |
|
||||||
| `AO_AGENT` | `claude-code` | Compatibility agent adapter |
|
| `AO_AGENT` | `claude-code` | Compatibility agent adapter |
|
||||||
| `GITHUB_TOKEN` | - | GitHub auth token |
|
| `GITHUB_TOKEN` | - | GitHub auth token |
|
||||||
|
|
||||||
### Health Checks
|
### Health Checks
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,14 +135,32 @@ func asExit(err error, target **exec.ExitError) bool {
|
||||||
return false
|
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) {
|
func (e env) startDaemon(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
out, code := e.run(t, "start")
|
cmd := exec.Command(aoBin, "daemon")
|
||||||
if code != 0 {
|
cmd.Env = e.environ("")
|
||||||
t.Fatalf("start failed (exit %d): %s", code, out)
|
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) {
|
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, `"state": "ready"`)
|
||||||
mustContain(t, out, fmt.Sprintf(`"port": %d`, e.port))
|
mustContain(t, out, fmt.Sprintf(`"port": %d`, e.port))
|
||||||
|
|
||||||
// idempotent
|
// the daemon (not the CLI) has created + migrated the store
|
||||||
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
|
|
||||||
if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err != nil {
|
if _, err := os.Stat(filepath.Join(e.dataDir, "ao.db")); err != nil {
|
||||||
t.Fatalf("daemon should have created ao.db: %v", err)
|
t.Fatalf("daemon should have created ao.db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestStopRemovesStaleRunFile(t *testing.T) {
|
||||||
cfg := setConfigEnv(t)
|
cfg := setConfigEnv(t)
|
||||||
if err := runfile.Write(cfg.runFile, runfile.Info{PID: 999999, Port: 3001, StartedAt: time.Unix(100, 0).UTC()}); err != nil {
|
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 {
|
type testConfig struct {
|
||||||
runFile string
|
runFile string
|
||||||
dataDir string
|
dataDir string
|
||||||
|
|
|
||||||
|
|
@ -2,196 +2,330 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"runtime"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
"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 {
|
type startOptions struct {
|
||||||
timeout time.Duration
|
json bool
|
||||||
logFile string
|
}
|
||||||
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 {
|
func newStartCommand(ctx *commandContext) *cobra.Command {
|
||||||
opts := startOptions{timeout: defaultStartTimeout}
|
opts := startOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "start",
|
Use: "start",
|
||||||
Short: "Start the AO daemon",
|
Short: "Fetch (if needed) and open the Agent Orchestrator desktop app",
|
||||||
Args: noArgs,
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
st, err := ctx.startDaemon(cmd.Context(), opts)
|
return ctx.runStart(cmd.Context(), cmd, opts)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ctx.emitCLIInvoked(cmd.Context(), cmd)
|
|
||||||
if opts.json {
|
|
||||||
return writeJSON(cmd.OutOrStdout(), st)
|
|
||||||
}
|
|
||||||
if st.State == stateReady {
|
|
||||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "AO daemon ready (pid %d, port %d)\n", st.PID, st.Port)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeStatus(cmd, st)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().DurationVar(&opts.timeout, "timeout", defaultStartTimeout, "How long to wait for daemon readiness")
|
|
||||||
cmd.Flags().StringVar(&opts.logFile, "log-file", "", "Daemon log file path")
|
|
||||||
cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON")
|
cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON")
|
||||||
return cmd
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return daemonStatus{}, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// running.json lives directly under ~/.ao; the marker sits beside it.
|
||||||
st, err := c.inspectDaemon(ctx)
|
return filepath.Dir(cfg.RunFilePath), nil
|
||||||
if err != nil {
|
|
||||||
return daemonStatus{}, err
|
|
||||||
}
|
|
||||||
if st.State == stateReady {
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
if st.State != stateStopped && st.State != stateStale {
|
|
||||||
ready, waitErr := c.waitForReady(ctx, opts.timeout)
|
|
||||||
if waitErr == nil {
|
|
||||||
return ready, nil
|
|
||||||
}
|
|
||||||
return daemonStatus{}, fmt.Errorf("daemon process exists but did not become ready: %w", waitErr)
|
|
||||||
}
|
|
||||||
if st.State == stateStale {
|
|
||||||
if err := runfile.Remove(cfg.RunFilePath); err != nil {
|
|
||||||
return daemonStatus{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-boot opt-in: before launching the daemon (so the import runs with the
|
|
||||||
// store unlocked and the daemon as sole writer afterwards), offer to import a
|
|
||||||
// legacy AO install. Declining or any import failure is non-fatal — the
|
|
||||||
// daemon still starts and the user can run `ao import` later.
|
|
||||||
c.maybeFirstBootImport(ctx, cfg)
|
|
||||||
|
|
||||||
exe, err := c.deps.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return daemonStatus{}, fmt.Errorf("resolve executable: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logPath := opts.logFile
|
|
||||||
if logPath == "" {
|
|
||||||
logPath = filepath.Join(filepath.Dir(cfg.RunFilePath), "daemon.log")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil {
|
|
||||||
return daemonStatus{}, fmt.Errorf("create log dir: %w", err)
|
|
||||||
}
|
|
||||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return daemonStatus{}, fmt.Errorf("open daemon log: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = logFile.Close() }()
|
|
||||||
|
|
||||||
if err := c.deps.StartProcess(processStartConfig{
|
|
||||||
Path: exe,
|
|
||||||
Args: []string{"daemon"},
|
|
||||||
Env: os.Environ(),
|
|
||||||
Stdout: logFile,
|
|
||||||
Stderr: logFile,
|
|
||||||
}); err != nil {
|
|
||||||
return daemonStatus{}, fmt.Errorf("start daemon: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ready, err := c.waitForReady(ctx, opts.timeout)
|
|
||||||
if err != nil {
|
|
||||||
return daemonStatus{}, fmt.Errorf("%w; see daemon log %s", err, logPath)
|
|
||||||
}
|
|
||||||
return ready, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeFirstBootImport offers to import a legacy AO install the first time the
|
// knownAppLocations lists the platform's standard install paths to scan when the
|
||||||
// daemon is started against an empty rewrite database. It is best-effort: every
|
// marker misses (covers website installs and stale markers, spec §6.2).
|
||||||
// failure path degrades to "start the daemon fresh" so a broken or absent legacy
|
func knownAppLocations() []string {
|
||||||
// store can never block startup. A non-interactive boot (Electron/headless)
|
switch runtime.GOOS {
|
||||||
// never auto-imports; it prints a one-line hint to run `ao import` explicitly.
|
case "darwin":
|
||||||
func (c *commandContext) maybeFirstBootImport(ctx context.Context, cfg config.Config) {
|
paths := []string{filepath.Join("/Applications", appBundleName)}
|
||||||
root := legacyimport.DefaultLegacyRootDir()
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
if !legacyimport.HasLegacyData(root) {
|
paths = append(paths, filepath.Join(home, "Applications", appBundleName))
|
||||||
return
|
}
|
||||||
|
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) {
|
// isUsableBundle reports whether p stats as a usable app bundle. On macOS a
|
||||||
if timeout <= 0 {
|
// bundle is a directory; the filesystem is the source of truth (invariant 2).
|
||||||
timeout = defaultStartTimeout
|
func isUsableBundle(p string) bool {
|
||||||
|
if p == "" {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
deadline := c.deps.Now().Add(timeout)
|
info, err := os.Stat(p)
|
||||||
var last daemonStatus
|
if err != nil {
|
||||||
var lastErr error
|
return false
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
return info.IsDir()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
// fetchApp downloads the latest desktop release for this platform, unpacks it
|
||||||
select {
|
// into a staging dir under ~/.ao/staging, and returns the bundle path (spec
|
||||||
case <-ctx.Done():
|
// §6.3). Windows/Linux are tracked as T6/T7.
|
||||||
return daemonStatus{}, ctx.Err()
|
func (c *commandContext) fetchApp(ctx context.Context) (string, error) {
|
||||||
default:
|
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)
|
asset, err := darwinAssetName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
return "", err
|
||||||
} else {
|
}
|
||||||
last = st
|
url := downloadURL(asset)
|
||||||
if st.State == stateReady {
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.deps.Now().Before(deadline) {
|
stateDir, err := aoStateDir()
|
||||||
if lastErr != nil {
|
if err != nil {
|
||||||
return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s: %w", timeout, lastErr)
|
return "", err
|
||||||
}
|
}
|
||||||
return daemonStatus{}, fmt.Errorf("daemon did not become ready within %s (last state: %s)", timeout, last.State)
|
staging := filepath.Join(stateDir, "staging")
|
||||||
}
|
// Clear any stale or partial prior unpack so ditto extracts into a clean dir
|
||||||
c.deps.Sleep(100 * time.Millisecond)
|
// (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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,7 @@ flowchart LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Input (`LaunchConfig`):**
|
**Input (`LaunchConfig`):**
|
||||||
|
|
||||||
- `SessionID` — AO session identifier
|
- `SessionID` — AO session identifier
|
||||||
- `ProjectID` — AO project identifier
|
- `ProjectID` — AO project identifier
|
||||||
- `Prompt` — User prompt to send
|
- `Prompt` — User prompt to send
|
||||||
|
|
@ -159,6 +160,7 @@ flowchart TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements:**
|
**Requirements:**
|
||||||
|
|
||||||
- Preserve all user hooks
|
- Preserve all user hooks
|
||||||
- Deduplicate AO hook entries
|
- Deduplicate AO hook entries
|
||||||
- Make AO hooks machine-portable (use `ao hooks ...` command)
|
- Make AO hooks machine-portable (use `ao hooks ...` command)
|
||||||
|
|
@ -169,11 +171,13 @@ flowchart TD
|
||||||
Builds a command to resume an existing session:
|
Builds a command to resume an existing session:
|
||||||
|
|
||||||
**Input (`RestoreConfig`):**
|
**Input (`RestoreConfig`):**
|
||||||
|
|
||||||
- `Session` — Complete session record with metadata
|
- `Session` — Complete session record with metadata
|
||||||
- `Permissions` — Permission mode to apply
|
- `Permissions` — Permission mode to apply
|
||||||
- `SystemPrompt` — System prompt to re-apply
|
- `SystemPrompt` — System prompt to re-apply
|
||||||
|
|
||||||
**Output:**
|
**Output:**
|
||||||
|
|
||||||
- `cmd` — Restore command argv
|
- `cmd` — Restore command argv
|
||||||
- `ok` — True if restore is supported
|
- `ok` — True if restore is supported
|
||||||
- `err` — Error if restore fails
|
- `err` — Error if restore fails
|
||||||
|
|
@ -242,13 +246,13 @@ sequenceDiagram
|
||||||
|
|
||||||
AO supports hooks for these agent events:
|
AO supports hooks for these agent events:
|
||||||
|
|
||||||
| Event | Purpose | Activity State |
|
| Event | Purpose | Activity State |
|
||||||
|-------|---------|----------------|
|
| ------------------ | --------------------- | ------------------ |
|
||||||
| `SessionStart` | Session initialized | - |
|
| `SessionStart` | Session initialized | - |
|
||||||
| `UserPromptSubmit` | User submitted prompt | `active` |
|
| `UserPromptSubmit` | User submitted prompt | `active` |
|
||||||
| `AssistantMessage` | Assistant response | - |
|
| `AssistantMessage` | Assistant response | - |
|
||||||
| `ToolUse` | Agent used a tool | `active` |
|
| `ToolUse` | Agent used a tool | `active` |
|
||||||
| `Stop` | Session stopped | `idle` or `exited` |
|
| `Stop` | Session stopped | `idle` or `exited` |
|
||||||
|
|
||||||
### Hook Contract
|
### Hook Contract
|
||||||
|
|
||||||
|
|
@ -288,6 +292,7 @@ flowchart TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical rules:**
|
**Critical rules:**
|
||||||
|
|
||||||
1. **Always exit 0** — Hook failure must never break the user's agent
|
1. **Always exit 0** — Hook failure must never break the user's agent
|
||||||
2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR`
|
2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR`
|
||||||
3. **Best-effort delivery** — The daemon may be temporarily unavailable
|
3. **Best-effort delivery** — The daemon may be temporarily unavailable
|
||||||
|
|
@ -302,9 +307,9 @@ Different agents use different hook mechanisms:
|
||||||
```json
|
```json
|
||||||
// .claude/hooks.json
|
// .claude/hooks.json
|
||||||
{
|
{
|
||||||
"SessionStart": ["ao hooks claude-code SessionStart"],
|
"SessionStart": ["ao hooks claude-code SessionStart"],
|
||||||
"UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"],
|
"UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"],
|
||||||
"Stop": ["ao hooks claude-code Stop"]
|
"Stop": ["ao hooks claude-code Stop"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -313,16 +318,16 @@ Different agents use different hook mechanisms:
|
||||||
```json
|
```json
|
||||||
// .factory/hooks.json
|
// .factory/hooks.json
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"event": "agent:beforeThinking",
|
"event": "agent:beforeThinking",
|
||||||
"command": "ao hooks droid beforeThinking"
|
"command": "ao hooks droid beforeThinking"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"event": "agent:afterThinking",
|
"event": "agent:afterThinking",
|
||||||
"command": "ao hooks droid afterThinking"
|
"command": "ao hooks droid afterThinking"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -797,16 +802,16 @@ backend/internal/adapters/agent/
|
||||||
|
|
||||||
### Adapter Capabilities
|
### Adapter Capabilities
|
||||||
|
|
||||||
| Agent | Permissions | System Prompt | Restore | Hooks |
|
| Agent | Permissions | System Prompt | Restore | Hooks |
|
||||||
|-------|-------------|---------------|---------|-------|
|
| ----------- | ----------- | ------------- | ------- | ----------------- |
|
||||||
| Claude Code | ✓ | ✓ | ✓ | ✓ |
|
| Claude Code | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Codex | ✓ | ✓ | ✓ | ✓ (session flags) |
|
| Codex | ✓ | ✓ | ✓ | ✓ (session flags) |
|
||||||
| Cursor | ✓ | ✓ | ✗ | ✗ |
|
| Cursor | ✓ | ✓ | ✗ | ✗ |
|
||||||
| OpenCode | ✓ | ✓ | ✗ | ✗ |
|
| OpenCode | ✓ | ✓ | ✗ | ✗ |
|
||||||
| Aider | ✓ | ✓ | ✓ | ✓ |
|
| Aider | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Amp | ✓ | ✓ | ✓ | ✓ |
|
| Amp | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) |
|
| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) |
|
||||||
| ... | ... | ... | ... | ... |
|
| ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/<arch>/`; Windows `MakerNSIS` → `Agent Orchestrator
|
||||||
|
Setup.exe` (per-user installer); Linux `maker-deb`/`maker-rpm` →
|
||||||
|
`agent-orchestrator-<version>.{deb,rpm}`.
|
||||||
|
- **No asset-rename step** and **`draft: true`** → a constant
|
||||||
|
`releases/latest/download/<stable-name>` 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/<owner>/<repo>/releases/latest/download/<stable-asset>`
|
||||||
|
(302 → asset; requires non-draft release + stable names, §8). `<owner>/<repo>` 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=<owner>/<repo>` 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 <app> --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=<tag>` 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/<stable-name>` 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.
|
||||||
|
|
@ -134,12 +134,14 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Shared IDs: `ProjectID`, `SessionID`, `IssueID`
|
- Shared IDs: `ProjectID`, `SessionID`, `IssueID`
|
||||||
- Enums and status vocabulary
|
- Enums and status vocabulary
|
||||||
- Durable fact records used across packages
|
- Durable fact records used across packages
|
||||||
- PR, tracker, project, session vocabulary
|
- PR, tracker, project, session vocabulary
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- HTTP request/response DTOs
|
- HTTP request/response DTOs
|
||||||
- CLI output shapes
|
- CLI output shapes
|
||||||
- OpenAPI wrapper types
|
- OpenAPI wrapper types
|
||||||
|
|
@ -169,11 +171,13 @@ graph LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Interfaces consumed by core packages, implemented by adapters
|
- Interfaces consumed by core packages, implemented by adapters
|
||||||
- Capability structs: `RuntimeConfig`, `WorkspaceConfig`, `SpawnConfig`
|
- Capability structs: `RuntimeConfig`, `WorkspaceConfig`, `SpawnConfig`
|
||||||
- Vocabulary at the boundary between core and adapters
|
- Vocabulary at the boundary between core and adapters
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Resource read models (belongs in `service/*`)
|
- Resource read models (belongs in `service/*`)
|
||||||
- HTTP request/response DTOs (belongs in `httpd`)
|
- HTTP request/response DTOs (belongs in `httpd`)
|
||||||
- sqlc rows (belongs in `storage/sqlite`)
|
- sqlc rows (belongs in `storage/sqlite`)
|
||||||
|
|
@ -181,15 +185,15 @@ graph LR
|
||||||
|
|
||||||
**Key Port Interfaces:**
|
**Key Port Interfaces:**
|
||||||
|
|
||||||
| Port | Purpose | Implementations |
|
| Port | Purpose | Implementations |
|
||||||
|------|---------|-----------------|
|
| ---------------- | ----------------------- | ----------------------- |
|
||||||
| `Runtime` | Process isolation | `tmux`, `conpty` |
|
| `Runtime` | Process isolation | `tmux`, `conpty` |
|
||||||
| `Workspace` | Git worktree management | `gitworktree` |
|
| `Workspace` | Git worktree management | `gitworktree` |
|
||||||
| `Agent` | Agent launching | 23+ agent adapters |
|
| `Agent` | Agent launching | 23+ agent adapters |
|
||||||
| `SCM` | PR/CI observation | `github` |
|
| `SCM` | PR/CI observation | `github` |
|
||||||
| `Tracker` | Issue tracking | `github` (adapter only) |
|
| `Tracker` | Issue tracking | `github` (adapter only) |
|
||||||
| `AgentMessenger` | Agent communication | Agent hooks |
|
| `AgentMessenger` | Agent communication | Agent hooks |
|
||||||
| `PRWriter` | PR persistence | `pr.Manager` |
|
| `PRWriter` | PR persistence | `pr.Manager` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -243,6 +247,7 @@ graph LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Resource use cases called by HTTP controllers and CLI
|
- Resource use cases called by HTTP controllers and CLI
|
||||||
- Resource read models and command/result types
|
- Resource read models and command/result types
|
||||||
- Display-model assembly (e.g., session status derivation)
|
- Display-model assembly (e.g., session status derivation)
|
||||||
|
|
@ -250,6 +255,7 @@ graph LR
|
||||||
- Small store interfaces consumed by the service
|
- Small store interfaces consumed by the service
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Low-level runtime/workspace/agent process control
|
- Low-level runtime/workspace/agent process control
|
||||||
- Raw sqlc generated rows as public results
|
- Raw sqlc generated rows as public results
|
||||||
- HTTP routing, path parsing, status-code decisions
|
- HTTP routing, path parsing, status-code decisions
|
||||||
|
|
@ -284,12 +290,14 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Multi-step session mutations with rollback
|
- Multi-step session mutations with rollback
|
||||||
- Resource sequencing (workspace → runtime → agent)
|
- Resource sequencing (workspace → runtime → agent)
|
||||||
- Resource teardown safety and cleanup
|
- Resource teardown safety and cleanup
|
||||||
- Internal errors: not found, terminated, not restorable
|
- Internal errors: not found, terminated, not restorable
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- HTTP request decoding
|
- HTTP request decoding
|
||||||
- CLI formatting
|
- CLI formatting
|
||||||
- Controller-facing list/get read-model assembly
|
- Controller-facing list/get read-model assembly
|
||||||
|
|
@ -340,11 +348,13 @@ graph LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Updates to lifecycle-owned session facts
|
- Updates to lifecycle-owned session facts
|
||||||
- Guardrails around runtime/activity observations
|
- Guardrails around runtime/activity observations
|
||||||
- Lifecycle-triggered agent nudges for actionable PR facts
|
- Lifecycle-triggered agent nudges for actionable PR facts
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Display status persistence (use service layer instead)
|
- Display status persistence (use service layer instead)
|
||||||
- HTTP/CLI DTOs
|
- HTTP/CLI DTOs
|
||||||
- Direct adapter implementation details
|
- Direct adapter implementation details
|
||||||
|
|
@ -385,15 +395,18 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Current observation packages:**
|
**Current observation packages:**
|
||||||
|
|
||||||
- `internal/observe/scm` — SCM (GitHub) observer loop
|
- `internal/observe/scm` — SCM (GitHub) observer loop
|
||||||
- `internal/observe/reaper` — Runtime liveness observation loop
|
- `internal/observe/reaper` — Runtime liveness observation loop
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Polling loops and observation logic
|
- Polling loops and observation logic
|
||||||
- External state transformation into domain facts
|
- External state transformation into domain facts
|
||||||
- Observation error handling and retry logic
|
- Observation error handling and retry logic
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Product workflow decisions (belongs in service layer)
|
- Product workflow decisions (belongs in service layer)
|
||||||
- Direct storage writes (use lifecycle instead)
|
- Direct storage writes (use lifecycle instead)
|
||||||
|
|
||||||
|
|
@ -420,6 +433,7 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Connection setup and PRAGMAs
|
- Connection setup and PRAGMAs
|
||||||
- Goose migrations
|
- Goose migrations
|
||||||
- sqlc queries and generated code
|
- sqlc queries and generated code
|
||||||
|
|
@ -427,6 +441,7 @@ graph TD
|
||||||
- Transactions and CDC-triggered persistence behavior
|
- Transactions and CDC-triggered persistence behavior
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- HTTP response types
|
- HTTP response types
|
||||||
- CLI output formatting
|
- CLI output formatting
|
||||||
- Product display status rules
|
- Product display status rules
|
||||||
|
|
@ -454,11 +469,13 @@ graph LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Event type definitions for the CDC stream
|
- Event type definitions for the CDC stream
|
||||||
- Poller and broadcaster logic
|
- Poller and broadcaster logic
|
||||||
- Subscriber fan-out behavior
|
- Subscriber fan-out behavior
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Terminal byte streams (belongs in `internal/terminal`)
|
- Terminal byte streams (belongs in `internal/terminal`)
|
||||||
- Product workflow decisions (belongs in service layer)
|
- Product workflow decisions (belongs in service layer)
|
||||||
- Database schema ownership (belongs in `storage/sqlite`)
|
- Database schema ownership (belongs in `storage/sqlite`)
|
||||||
|
|
@ -485,11 +502,13 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Per-client attachment lifecycle
|
- Per-client attachment lifecycle
|
||||||
- Input/output framing independent of HTTP
|
- Input/output framing independent of HTTP
|
||||||
- PTY-backed attach handling and terminal protocol tests
|
- PTY-backed attach handling and terminal protocol tests
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- HTTP-specific concerns (belongs in `httpd`)
|
- HTTP-specific concerns (belongs in `httpd`)
|
||||||
- HTTP routing or WebSocket upgrade logic
|
- HTTP routing or WebSocket upgrade logic
|
||||||
|
|
||||||
|
|
@ -520,6 +539,7 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Routing and middleware
|
- Routing and middleware
|
||||||
- HTTP request decoding and response encoding
|
- HTTP request decoding and response encoding
|
||||||
- Path/query parameter handling
|
- Path/query parameter handling
|
||||||
|
|
@ -529,6 +549,7 @@ graph TD
|
||||||
- WebSocket upgrade handling for terminal mux
|
- WebSocket upgrade handling for terminal mux
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Direct adapter or SQLite store access
|
- Direct adapter or SQLite store access
|
||||||
- Application read models shared with CLI (belongs in `service/*`)
|
- Application read models shared with CLI (belongs in `service/*`)
|
||||||
|
|
||||||
|
|
@ -556,12 +577,14 @@ graph LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Daemon discovery
|
- Daemon discovery
|
||||||
- HTTP API calls
|
- HTTP API calls
|
||||||
- Command output formatting
|
- Command output formatting
|
||||||
- Process control: start/stop/status/doctor
|
- Process control: start/stop/status/doctor
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Duplicate daemon business logic (put in daemon service/API)
|
- Duplicate daemon business logic (put in daemon service/API)
|
||||||
- Direct storage, runtime, or adapter access
|
- Direct storage, runtime, or adapter access
|
||||||
|
|
||||||
|
|
@ -592,12 +615,14 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Adapter principles:**
|
**Adapter principles:**
|
||||||
|
|
||||||
- Adapters are leaves in the import graph
|
- Adapters are leaves in the import graph
|
||||||
- Adapters translate external behavior into AO ports/domain concepts
|
- Adapters translate external behavior into AO ports/domain concepts
|
||||||
- Adapters should not own product workflows
|
- Adapters should not own product workflows
|
||||||
- All adapter-written files must be gitignored
|
- All adapter-written files must be gitignored
|
||||||
|
|
||||||
**Good dependencies:**
|
**Good dependencies:**
|
||||||
|
|
||||||
```
|
```
|
||||||
session_manager → ports.Runtime
|
session_manager → ports.Runtime
|
||||||
adapters/runtime/tmux → ports + domain
|
adapters/runtime/tmux → ports + domain
|
||||||
|
|
@ -606,6 +631,7 @@ daemon → adapters + services + storage
|
||||||
```
|
```
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
|
|
||||||
```
|
```
|
||||||
domain → adapters
|
domain → adapters
|
||||||
service/session → adapters/runtime/tmux
|
service/session → adapters/runtime/tmux
|
||||||
|
|
@ -635,14 +661,16 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Belongs here:**
|
**Belongs here:**
|
||||||
|
|
||||||
- Production dependency construction
|
- Production dependency construction
|
||||||
- Adapter registration
|
- Adapter registration
|
||||||
- Startup/shutdown sequencing
|
- Startup/shutdown sequencing
|
||||||
- Cross-component wiring
|
- Cross-component wiring
|
||||||
|
|
||||||
**Does NOT belong here:**
|
**Does NOT belong here:**
|
||||||
|
|
||||||
- Business logic (belongs in service, lifecycle, or manager packages)
|
- 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:**
|
**Key environment variables:**
|
||||||
|
|
||||||
- `AO_PORT` — HTTP bind port (default: 3001)
|
- `AO_PORT` — HTTP bind port (default: 3001)
|
||||||
- `AO_REQUEST_TIMEOUT` — Per-request timeout (default: 60s)
|
- `AO_REQUEST_TIMEOUT` — Per-request timeout (default: 60s)
|
||||||
- `AO_SHUTDOWN_TIMEOUT` — Graceful shutdown cap (default: 10s)
|
- `AO_SHUTDOWN_TIMEOUT` — Graceful shutdown cap (default: 10s)
|
||||||
|
|
@ -769,6 +798,7 @@ graph TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key patterns:**
|
**Key patterns:**
|
||||||
|
|
||||||
- All arrows point downward (no cycles)
|
- All arrows point downward (no cycles)
|
||||||
- Adapters and domain are leaves
|
- Adapters and domain are leaves
|
||||||
- CLI and HTTPD don't touch storage directly
|
- CLI and HTTPD don't touch storage directly
|
||||||
|
|
@ -790,6 +820,7 @@ flowchart LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Add controller in `httpd/controllers/`
|
1. Add controller in `httpd/controllers/`
|
||||||
2. Call a `service/*` package
|
2. Call a `service/*` package
|
||||||
3. Update OpenAPI generation
|
3. Update OpenAPI generation
|
||||||
|
|
@ -808,6 +839,7 @@ flowchart TD
|
||||||
```
|
```
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Add shared IDs/vocabulary to `domain`
|
1. Add shared IDs/vocabulary to `domain`
|
||||||
2. Create use cases in `service/<resource>`
|
2. Create use cases in `service/<resource>`
|
||||||
3. Add storage in `storage/sqlite`
|
3. Add storage in `storage/sqlite`
|
||||||
|
|
@ -828,6 +860,7 @@ flowchart LR
|
||||||
```
|
```
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Implement a `ports` interface under `adapters/<capability>/<impl>`
|
1. Implement a `ports` interface under `adapters/<capability>/<impl>`
|
||||||
2. For agents: implement hooks with gitignored files
|
2. For agents: implement hooks with gitignored files
|
||||||
3. Wire in `daemon`
|
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
|
6. **Daemon** wires it all — composition root
|
||||||
|
|
||||||
**Always ask:**
|
**Always ask:**
|
||||||
|
|
||||||
- Does this belong in domain (shared concept)?
|
- Does this belong in domain (shared concept)?
|
||||||
- Does this belong in ports (shared capability)?
|
- Does this belong in ports (shared capability)?
|
||||||
- Does this belong in service (use case)?
|
- Does this belong in service (use case)?
|
||||||
- Does this belong in adapters (external system)?
|
- Does this belong in adapters (external system)?
|
||||||
|
|
||||||
**Never:**
|
**Never:**
|
||||||
|
|
||||||
- Put HTTP types in domain
|
- Put HTTP types in domain
|
||||||
- Put display status in storage
|
- Put display status in storage
|
||||||
- Put business logic in CLI
|
- Put business logic in CLI
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,22 @@ import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||||
import MakerNSIS from "./makers/maker-nsis";
|
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 = {
|
const config: ForgeConfig = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: true,
|
||||||
|
|
@ -82,10 +98,22 @@ const config: ForgeConfig = {
|
||||||
publishers: [
|
publishers: [
|
||||||
{
|
{
|
||||||
name: "@electron-forge/publisher-github",
|
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: {
|
config: {
|
||||||
repository: { owner: "aoagents", name: "agent-orchestrator" },
|
repository: parseReleaseRepo(process.env.AO_RELEASE_REPO),
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
draft: true,
|
// draft:false so the release is immediately live, which is what the
|
||||||
|
// `ao start` bootstrapper's constant
|
||||||
|
// releases/latest/download/<asset> 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { buildTelemetryBootstrap } from "./shared/telemetry";
|
||||||
import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host";
|
import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host";
|
||||||
import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link";
|
import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link";
|
||||||
import { shouldLinkOnAttach } from "./main/daemon-owner";
|
import { shouldLinkOnAttach } from "./main/daemon-owner";
|
||||||
|
import { writeAppStateMarker } from "./main/app-state";
|
||||||
|
|
||||||
// Globals injected at compile time by @electron-forge/plugin-vite.
|
// Globals injected at compile time by @electron-forge/plugin-vite.
|
||||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
|
||||||
|
|
@ -819,7 +820,86 @@ function initAutoUpdates(): void {
|
||||||
updateElectronApp();
|
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/<exe>;
|
||||||
|
// 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=<value>` 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<void> {
|
||||||
|
// 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();
|
registerRendererProtocol();
|
||||||
createWindow();
|
createWindow();
|
||||||
void startDaemon();
|
void startDaemon();
|
||||||
|
|
|
||||||
|
|
@ -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<AppStateMarker> {
|
||||||
|
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<string, unknown>);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<AppStateMarker | null> {
|
||||||
|
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<void> {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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-<platform>-<arch>` 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-<platform>-<arch>` 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);
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 <repo>/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
|
||||||
Loading…
Reference in New Issue