chore: resolve merge conflict in SessionDetail.tsx
Merge main into branch. main removed the relaunchError banner and the onRelaunchClean header prop (#1981 simplified the orchestrator header); this branch added the CanvasRail flex wrapper. Take main's removal of the relaunch UI and keep the CanvasRail wrapper around <main>. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
e4e073fb53
|
|
@ -8,6 +8,7 @@
|
|||
"@aoagents/ao-core",
|
||||
"@aoagents/ao-cli",
|
||||
"@aoagents/ao",
|
||||
"@aoagents/ao-notifier-macos",
|
||||
"@aoagents/ao-plugin-runtime-tmux",
|
||||
"@aoagents/ao-plugin-runtime-process",
|
||||
"@aoagents/ao-plugin-agent-claude-code",
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
"@aoagents/ao-plugin-notifier-slack",
|
||||
"@aoagents/ao-plugin-notifier-webhook",
|
||||
"@aoagents/ao-plugin-notifier-composio",
|
||||
"@aoagents/ao-plugin-notifier-dashboard",
|
||||
"@aoagents/ao-plugin-notifier-discord",
|
||||
"@aoagents/ao-plugin-notifier-openclaw",
|
||||
"@aoagents/ao-plugin-terminal-iterm2",
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
]
|
||||
],
|
||||
"snapshot": {
|
||||
"useCalculatedVersionForSnapshots": true,
|
||||
"useCalculatedVersion": true,
|
||||
"prereleaseTemplate": "{tag}-{commit}"
|
||||
},
|
||||
"access": "public",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
"@aoagents/ao": patch
|
||||
"@aoagents/ao-cli": patch
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-web": patch
|
||||
"@aoagents/ao-notifier-macos": patch
|
||||
"@aoagents/ao-plugin-agent-aider": patch
|
||||
"@aoagents/ao-plugin-agent-claude-code": patch
|
||||
"@aoagents/ao-plugin-agent-codex": patch
|
||||
"@aoagents/ao-plugin-agent-cursor": patch
|
||||
"@aoagents/ao-plugin-agent-grok": patch
|
||||
"@aoagents/ao-plugin-agent-kimicode": patch
|
||||
"@aoagents/ao-plugin-agent-opencode": patch
|
||||
"@aoagents/ao-plugin-notifier-composio": patch
|
||||
"@aoagents/ao-plugin-notifier-dashboard": patch
|
||||
"@aoagents/ao-plugin-notifier-desktop": patch
|
||||
"@aoagents/ao-plugin-notifier-discord": patch
|
||||
"@aoagents/ao-plugin-notifier-openclaw": patch
|
||||
"@aoagents/ao-plugin-notifier-slack": patch
|
||||
"@aoagents/ao-plugin-notifier-webhook": patch
|
||||
"@aoagents/ao-plugin-runtime-process": patch
|
||||
"@aoagents/ao-plugin-runtime-tmux": patch
|
||||
"@aoagents/ao-plugin-scm-github": patch
|
||||
"@aoagents/ao-plugin-scm-gitlab": patch
|
||||
"@aoagents/ao-plugin-terminal-iterm2": patch
|
||||
"@aoagents/ao-plugin-terminal-web": patch
|
||||
"@aoagents/ao-plugin-tracker-github": patch
|
||||
"@aoagents/ao-plugin-tracker-gitlab": patch
|
||||
"@aoagents/ao-plugin-tracker-linear": patch
|
||||
"@aoagents/ao-plugin-workspace-clone": patch
|
||||
"@aoagents/ao-plugin-workspace-worktree": patch
|
||||
---
|
||||
|
||||
Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-web": minor
|
||||
---
|
||||
|
||||
feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
|
||||
|
||||
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
"@aoagents/ao": patch
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-cli": patch
|
||||
---
|
||||
|
||||
Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
|
||||
|
|
@ -111,7 +111,31 @@ jobs:
|
|||
# create a minimal one. `changeset version --snapshot` consumes and
|
||||
# deletes it, so no cleanup is needed.
|
||||
if [ -z "$(ls .changeset/*.md 2>/dev/null | grep -vi 'README')" ]; then
|
||||
printf -- '---\n"@aoagents/ao": patch\n---\n\nchore: canary build\n' > .changeset/canary-temp.md
|
||||
node << 'SCRIPT'
|
||||
const fs = require('fs');
|
||||
const pkgs = [];
|
||||
|
||||
const addPackage = (m) => {
|
||||
if (!fs.existsSync(m)) return;
|
||||
const d = JSON.parse(fs.readFileSync(m, 'utf8'));
|
||||
if (!d.private && d.publishConfig?.access === 'public') pkgs.push(d.name);
|
||||
};
|
||||
|
||||
const scanPackages = (dir) => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) addPackage(`${dir}/${entry.name}/package.json`);
|
||||
}
|
||||
};
|
||||
|
||||
scanPackages('packages');
|
||||
scanPackages('packages/plugins');
|
||||
|
||||
let body = '---\n';
|
||||
pkgs.forEach(p => body += '"' + p + '": patch\n');
|
||||
body += '---\n\nchore: canary build\n';
|
||||
fs.writeFileSync('.changeset/canary-temp.md', body);
|
||||
SCRIPT
|
||||
fi
|
||||
pnpm changeset version --snapshot nightly
|
||||
env:
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -210,6 +210,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
|||
### ao start
|
||||
- Registers in `running.json` (PID, port, projects)
|
||||
- Offers to restore sessions from `last-stop.json` — includes cross-project sessions via `otherProjects` field
|
||||
- `ao start --restore` restores `last-stop.json` without prompting; `ao start --no-restore` skips restore
|
||||
- **Ctrl+C performs full graceful shutdown** (same as ao stop): kills all sessions, writes last-stop state, unregisters from running.json. 10s hard timeout guarantees exit.
|
||||
|
||||
### ao stop
|
||||
|
|
@ -218,6 +219,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
|||
- Always loads global config (`~/.agent-orchestrator/config.yaml`) to see all projects — local config only has the cwd project
|
||||
- Records `LastStopState` with `otherProjects` field for cross-project session restore
|
||||
|
||||
### ao update
|
||||
- For package-manager installs, `ao update` pauses a running AO via `ao stop --yes`, runs the global package update, verifies `ao --version`, then restarts with `ao start --restore` (or `--no-restore` if requested)
|
||||
- Failed package-manager updates must report that AO was not updated, include actionable remediation, and restart the previous installation if AO was paused
|
||||
|
||||
### Dashboard sidebar
|
||||
- Sidebar always shows sessions from ALL projects regardless of which project page is active
|
||||
- `useSessionEvents` in Dashboard.tsx is called without project filter — sidebar gets unscoped sessions
|
||||
|
|
|
|||
7
SETUP.md
7
SETUP.md
|
|
@ -59,6 +59,13 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
|
|||
- Create incoming webhook: https://api.slack.com/messaging/webhooks
|
||||
- Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."`
|
||||
|
||||
- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik)
|
||||
- Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard
|
||||
- All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:<port>`
|
||||
- Example: `export AO_PUBLIC_URL="https://ao.example.com"`
|
||||
- When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`.
|
||||
- **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side.
|
||||
|
||||
## Installation
|
||||
|
||||
### Install via npm (recommended)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ ao stop # Stop everything (dashboard, orchestrato
|
|||
ao status # Overview of all sessions
|
||||
ao status --watch # Live-updating terminal status view
|
||||
ao dashboard # Open web dashboard in browser
|
||||
ao setup dashboard # Configure dashboard notification retention/routing
|
||||
ao setup desktop # Install/configure native macOS desktop notifications
|
||||
ao notify test --to desktop # Send a manual notifier test without starting AO
|
||||
ao completion zsh # Print the zsh completion script
|
||||
```
|
||||
|
||||
|
|
@ -42,6 +45,7 @@ ao session restore <session> # Revive a crashed agent
|
|||
```bash
|
||||
ao doctor # Check install, runtime, and stale temp issues
|
||||
ao doctor --fix # Apply safe fixes automatically
|
||||
ao setup openclaw # Connect AO notifications to OpenClaw
|
||||
ao update # Update local AO install (source installs only)
|
||||
ao config-help # Show full config schema reference
|
||||
```
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ This document describes runtime observability emitted by Agent Orchestrator.
|
|||
|
||||
## Emission Model
|
||||
|
||||
- **Structured logs**: JSON lines on stderr, controlled by `AO_LOG_LEVEL`.
|
||||
- **Structured logs**: JSON lines on stderr when enabled by config or `AO_OBSERVABILITY_STDERR`.
|
||||
- Supported levels: `debug`, `info`, `warn`, `error`.
|
||||
- Default level: `warn` (production-safe, avoids high-volume info logs).
|
||||
- Default stderr mirroring: disabled.
|
||||
- Runtime env vars override YAML:
|
||||
- `AO_LOG_LEVEL=info`
|
||||
- `AO_OBSERVABILITY_STDERR=1`
|
||||
- **Durable snapshots**: process-local JSON snapshots under:
|
||||
- `~/.agent-orchestrator/{config-hash}-observability/processes/*.json`
|
||||
- **Aggregated view**: merged by project via:
|
||||
|
|
@ -38,6 +42,7 @@ Counters are emitted per project and operation:
|
|||
- `lifecycle_poll` (`lifecycle.merge_cleanup.completed`) — auto-cleanup ran after a PR was detected as merged; session runtime + worktree + metadata were torn down
|
||||
- `lifecycle_poll` (`lifecycle.merge_cleanup.deferred`) — auto-cleanup is waiting for the agent to idle (or for the `mergeCleanupIdleGraceMs` window to elapse) before tearing down
|
||||
- `lifecycle_poll` (`lifecycle.merge_cleanup.failed`) — auto-cleanup threw during `sessionManager.kill()`; the session stays in `merged` so the next poll retries
|
||||
- `notification_delivery` (`notification.deliver`) — per-target notifier dispatch success/failure, with event ID/type, priority, project/session IDs, target reference, plugin name, and delivery method
|
||||
- `api_request` (web API routes)
|
||||
- `sse_connect`, `sse_snapshot`, `sse_disconnect`
|
||||
- `websocket_connect`, `websocket_disconnect`, `websocket_error` (websocket servers)
|
||||
|
|
@ -83,10 +88,11 @@ Health records provide current status and failure context per surface:
|
|||
- **Dashboard**: use **Copy debug info** in the hero toolbar (desktop) to copy `/api/observability` plus page URL, project scope, and correlation id to the clipboard for issue reports. The observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason.
|
||||
- **API**: `/api/observability` returns merged per-project diagnostics (`overallStatus`, metrics, health, recent traces, session state).
|
||||
- **Terminal websocket health**: `/health` endpoints include active sessions and websocket/terminal health counters with last error/disconnect reasons.
|
||||
- **Notifications**: successful deliveries update `notification_delivery` metrics and per-target health; failed/missing targets also appear in `ao events`.
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
1. Deploy with default `AO_LOG_LEVEL=warn` to avoid noisy logs.
|
||||
1. Deploy with default `observability.logLevel: warn` and `observability.stderr: false` to avoid noisy logs.
|
||||
2. Validate `/api/observability` and dashboard banner in a canary environment.
|
||||
3. If deeper triage is needed, temporarily raise `AO_LOG_LEVEL=info` (or `debug`), then revert to `warn`.
|
||||
3. If deeper triage is needed, temporarily raise `AO_LOG_LEVEL=info` (or `debug`) and set `AO_OBSERVABILITY_STDERR=1`, then revert to defaults.
|
||||
4. Monitor `lastFailureReason` and surface-level `reason` fields before enabling broader rollout.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,21 @@ export default tseslint.config(
|
|||
"no-debugger": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-template-curly-in-string": "warn",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.name='createInterface'] Property[key.name='input'] > CallExpression[callee.name='createReadStream']",
|
||||
message:
|
||||
"Do not pass createReadStream() inline to createInterface(); keep the stream in a variable and close/destroy it in a finally block.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.name='createInterface'] Property[key.name='input'] > CallExpression[callee.property.name='createReadStream']",
|
||||
message:
|
||||
"Do not pass createReadStream() inline to createInterface(); keep the stream in a variable and close/destroy it in a finally block.",
|
||||
},
|
||||
],
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
# @aoagents/ao
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [ecdf0c7]
|
||||
- Updated dependencies [fcedb25]
|
||||
- Updated dependencies [2980570]
|
||||
- Updated dependencies [d5d0f07]
|
||||
- @aoagents/ao-cli@0.9.0
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
<div align="center">
|
||||
|
||||
# Agent Orchestrator (`ao`)
|
||||
|
||||
**The orchestration layer for parallel AI coding agents.**
|
||||
|
||||
[](https://www.npmjs.com/package/@aoagents/ao)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/blob/main/LICENSE)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator)
|
||||
[](https://discord.gg/UZv7JjxbwG)
|
||||
|
||||
<img width="800" alt="Agent Orchestrator" src="https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/docs/assets/agent_orchestrator_banner.png">
|
||||
|
||||
</div>
|
||||
|
||||
Spawn parallel AI coding agents, each in its own git worktree, on a single machine. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise the whole fleet from one dashboard.
|
||||
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider, Cursor, OpenCode) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear, GitLab)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g @aoagents/ao
|
||||
```
|
||||
|
||||
> **Nightly builds** (latest `main`): `npm install -g @aoagents/ao@nightly` — back to stable with `@latest`.
|
||||
|
||||
**Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), the [`gh` CLI](https://cli.github.com), and at least one coding-agent CLI (e.g. [Claude Code](https://www.anthropic.com/claude-code)).
|
||||
|
||||
- **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — `brew install tmux` or `sudo apt install tmux`.
|
||||
- **Windows:** PowerShell 7+ recommended; tmux is **not** required (AO uses native ConPTY via the `process` runtime).
|
||||
|
||||
## Quick start
|
||||
|
||||
Point it at any repo — it clones, configures, and launches the dashboard in one command:
|
||||
|
||||
```bash
|
||||
ao start https://github.com/your-org/your-repo
|
||||
```
|
||||
|
||||
Or from inside an existing local repo:
|
||||
|
||||
```bash
|
||||
cd ~/your-project && ao start
|
||||
```
|
||||
|
||||
The dashboard opens at `http://localhost:3000` and an orchestrator agent starts managing your project. Add more repos any time:
|
||||
|
||||
```bash
|
||||
ao start ~/path/to/another-repo
|
||||
```
|
||||
|
||||
You don't need to learn the CLI — the dashboard and the orchestrator agent drive everything. (Individual `ao` commands are documented in the [CLI Reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md) and used internally by the orchestrator.)
|
||||
|
||||
## How it works
|
||||
|
||||
1. **You start** — `ao start` launches the dashboard and an orchestrator agent.
|
||||
2. **Orchestrator spawns workers** — each issue gets its own agent in an isolated git worktree and branch.
|
||||
3. **Agents work autonomously** — they read code, write tests, and open PRs.
|
||||
4. **Reactions handle feedback** — CI failures and review comments are routed back to the responsible agent automatically.
|
||||
5. **You review and merge** — you're pulled in only when human judgment is needed.
|
||||
|
||||
## Pluggable by design
|
||||
|
||||
Seven plugin slots; the lifecycle state machine stays in core:
|
||||
|
||||
| Slot | Default | Alternatives |
|
||||
| --- | --- | --- |
|
||||
| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker |
|
||||
| Agent | claude-code | codex, aider, cursor, opencode, kimicode |
|
||||
| Workspace | worktree | clone |
|
||||
| Tracker | github | linear, gitlab |
|
||||
| SCM | github | gitlab |
|
||||
| Notifier | desktop | slack, discord, composio, webhook, openclaw |
|
||||
| Terminal | iterm2 | web |
|
||||
|
||||
## Why Agent Orchestrator?
|
||||
|
||||
Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem: creating branches, detecting stuck agents, reading CI failures, forwarding review comments, tracking which PRs are ready, and cleaning up afterward.
|
||||
|
||||
Agent Orchestrator handles the isolation, feedback routing, and status tracking. You `ao start` and walk away — then review PRs and make decisions. The rest is automated.
|
||||
|
||||
## Documentation
|
||||
|
||||
- 📖 [Project README & overview](https://github.com/ComposioHQ/agent-orchestrator)
|
||||
- 🛠️ [Setup guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/SETUP.md) — install, configuration, troubleshooting
|
||||
- ⌨️ [CLI reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md)
|
||||
- 🧩 [Development & plugin guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/DEVELOPMENT.md)
|
||||
- 💬 [Discord community](https://discord.gg/UZv7JjxbwG)
|
||||
|
||||
## License
|
||||
|
||||
MIT © [ComposioHQ](https://github.com/ComposioHQ/agent-orchestrator)
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
{
|
||||
"name": "@aoagents/ao",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Orchestrate parallel AI coding agents — global CLI wrapper",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"ai-agents",
|
||||
"coding-agent",
|
||||
"agent-orchestration",
|
||||
"orchestrator",
|
||||
"claude-code",
|
||||
"codex",
|
||||
"aider",
|
||||
"cli",
|
||||
"automation",
|
||||
"devtools",
|
||||
"parallel-agents",
|
||||
"git-worktree"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -11,7 +26,8 @@
|
|||
"postinstall": "node bin/postinstall.js"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
"bin",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,58 @@
|
|||
# @aoagents/ao-cli
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
|
||||
- ecdf0c7: Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:<port>`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host.
|
||||
|
||||
Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.
|
||||
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
|
||||
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
|
||||
- Updated dependencies [73bed33]
|
||||
- Updated dependencies [a610601]
|
||||
- Updated dependencies [8c71bde]
|
||||
- Updated dependencies [7d9b862]
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [ee3fb5d]
|
||||
- Updated dependencies [fcedb25]
|
||||
- Updated dependencies [94981dc]
|
||||
- Updated dependencies [6d48022]
|
||||
- Updated dependencies [2980570]
|
||||
- Updated dependencies [d5d0f07]
|
||||
- Updated dependencies [07c9099]
|
||||
- @aoagents/ao-core@0.9.0
|
||||
- @aoagents/ao-web@0.9.0
|
||||
- @aoagents/ao-plugin-agent-claude-code@0.9.0
|
||||
- @aoagents/ao-plugin-tracker-linear@0.9.0
|
||||
- @aoagents/ao-notifier-macos@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-composio@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-dashboard@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-desktop@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-discord@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-openclaw@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-slack@0.9.0
|
||||
- @aoagents/ao-plugin-agent-aider@0.9.0
|
||||
- @aoagents/ao-plugin-agent-codex@0.9.0
|
||||
- @aoagents/ao-plugin-agent-cursor@0.9.0
|
||||
- @aoagents/ao-plugin-agent-grok@0.1.1
|
||||
- @aoagents/ao-plugin-agent-kimicode@0.9.0
|
||||
- @aoagents/ao-plugin-agent-opencode@0.9.0
|
||||
- @aoagents/ao-plugin-notifier-webhook@0.9.0
|
||||
- @aoagents/ao-plugin-runtime-process@0.9.0
|
||||
- @aoagents/ao-plugin-runtime-tmux@0.9.0
|
||||
- @aoagents/ao-plugin-scm-github@0.9.0
|
||||
- @aoagents/ao-plugin-terminal-iterm2@0.9.0
|
||||
- @aoagents/ao-plugin-terminal-web@0.9.0
|
||||
- @aoagents/ao-plugin-tracker-github@0.9.0
|
||||
- @aoagents/ao-plugin-workspace-clone@0.9.0
|
||||
- @aoagents/ao-plugin-workspace-worktree@0.9.0
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const {
|
|||
mockFindConfigFile,
|
||||
mockLoadConfig,
|
||||
mockCreatePluginRegistry,
|
||||
mockCreateProjectObserver,
|
||||
mockRecordNotificationDelivery,
|
||||
mockDetectOpenClawInstallation,
|
||||
mockValidateToken,
|
||||
mockRegistry,
|
||||
|
|
@ -18,6 +20,8 @@ const {
|
|||
mockFindConfigFile: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockCreatePluginRegistry: vi.fn(),
|
||||
mockCreateProjectObserver: vi.fn(),
|
||||
mockRecordNotificationDelivery: vi.fn(),
|
||||
mockDetectOpenClawInstallation: vi.fn(),
|
||||
mockValidateToken: vi.fn(),
|
||||
mockRegistry: {
|
||||
|
|
@ -36,10 +40,16 @@ vi.mock("../../src/lib/script-runner.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
buildCIFailureNotificationData: () => ({ schemaVersion: 3 }),
|
||||
buildPRStateNotificationData: () => ({ schemaVersion: 3 }),
|
||||
buildReactionNotificationData: () => ({ schemaVersion: 3 }),
|
||||
buildSessionTransitionNotificationData: () => ({ schemaVersion: 3 }),
|
||||
createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args),
|
||||
createProjectObserver: (...args: unknown[]) => mockCreateProjectObserver(...args),
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability",
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args),
|
||||
resolveNotifierTarget: (
|
||||
config: { notifiers?: Record<string, { plugin?: string }> },
|
||||
reference: string,
|
||||
|
|
@ -157,6 +167,12 @@ describe("doctor command", () => {
|
|||
|
||||
mockCreatePluginRegistry.mockReset();
|
||||
mockCreatePluginRegistry.mockReturnValue(mockRegistry);
|
||||
mockCreateProjectObserver.mockReset();
|
||||
mockCreateProjectObserver.mockReturnValue({
|
||||
recordOperation: vi.fn(),
|
||||
setHealth: vi.fn(),
|
||||
});
|
||||
mockRecordNotificationDelivery.mockReset();
|
||||
|
||||
mockRegistry.loadFromConfig.mockReset();
|
||||
mockRegistry.loadFromConfig.mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const { mockQueryActivityEvents, mockSearchActivityEvents, mockGetActivityEventStats } = vi.hoisted(
|
||||
() => ({
|
||||
mockQueryActivityEvents: vi.fn(),
|
||||
mockSearchActivityEvents: vi.fn(),
|
||||
mockGetActivityEventStats: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
queryActivityEvents: (...args: unknown[]) => mockQueryActivityEvents(...args),
|
||||
searchActivityEvents: (...args: unknown[]) => mockSearchActivityEvents(...args),
|
||||
getActivityEventStats: (...args: unknown[]) => mockGetActivityEventStats(...args),
|
||||
droppedEventCount: () => 0,
|
||||
isActivityEventsFtsEnabled: () => true,
|
||||
}));
|
||||
|
||||
import { registerEvents } from "../../src/commands/events.js";
|
||||
|
||||
describe("events command", () => {
|
||||
let program: Command;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerEvents(program);
|
||||
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
mockQueryActivityEvents.mockReset();
|
||||
mockSearchActivityEvents.mockReset();
|
||||
mockGetActivityEventStats.mockReset();
|
||||
mockQueryActivityEvents.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("filters list output by source and --kind alias", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"events",
|
||||
"list",
|
||||
"--source",
|
||||
"recovery",
|
||||
"--kind",
|
||||
"metadata.corrupt_detected",
|
||||
"--limit",
|
||||
"1",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(mockQueryActivityEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "recovery",
|
||||
kind: "metadata.corrupt_detected",
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('"source": "recovery"'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"kind": "metadata.corrupt_detected"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps --type as the existing event-kind filter", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"events",
|
||||
"list",
|
||||
"--type",
|
||||
"recovery.session_failed",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(mockQueryActivityEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "recovery.session_failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Tests for migrate-storage activity-event instrumentation (issue #1654).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockMigrateStorage, mockRollbackStorage } = vi.hoisted(() => ({
|
||||
mockMigrateStorage: vi.fn(),
|
||||
mockRollbackStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
migrateStorage: (...args: unknown[]) => mockMigrateStorage(...args),
|
||||
rollbackStorage: (...args: unknown[]) => mockRollbackStorage(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerMigrateStorage } from "../../src/commands/migrate-storage.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
describe("ao migrate-storage — activity events", () => {
|
||||
let program: Command;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(AoCore.recordActivityEvent).mockClear();
|
||||
mockMigrateStorage.mockReset();
|
||||
mockRollbackStorage.mockReset();
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerMigrateStorage(program);
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleErrSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("emits cli.migration_invoked before migration work starts", async () => {
|
||||
mockMigrateStorage.mockImplementation(async () => {
|
||||
expect(recordedEvents()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_invoked",
|
||||
source: "cli",
|
||||
level: "info",
|
||||
data: expect.objectContaining({
|
||||
rollback: false,
|
||||
dryRun: true,
|
||||
force: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return { projects: 1 };
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage", "--dry-run", "--force"]);
|
||||
|
||||
expect(mockMigrateStorage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("emits cli.migration_failed when migrateStorage throws", async () => {
|
||||
mockMigrateStorage.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
rollback: false,
|
||||
errorMessage: "disk full",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.migration_failed when rollbackStorage throws", async () => {
|
||||
mockRollbackStorage.mockRejectedValue(new Error("rollback boom"));
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage", "--rollback"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
rollback: true,
|
||||
errorMessage: "rollback boom",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const {
|
||||
mockCreatePluginRegistry,
|
||||
mockCreateProjectObserver,
|
||||
mockFindConfigFile,
|
||||
mockLoadConfig,
|
||||
mockRecordNotificationDelivery,
|
||||
mockRegistry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreatePluginRegistry: vi.fn(),
|
||||
mockCreateProjectObserver: vi.fn(),
|
||||
mockFindConfigFile: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockRecordNotificationDelivery: vi.fn(),
|
||||
mockRegistry: {
|
||||
loadFromConfig: vi.fn(),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
register: vi.fn(),
|
||||
loadBuiltins: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => {
|
||||
function buildSubject(input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
}) {
|
||||
return {
|
||||
session: { id: input.sessionId, projectId: input.projectId },
|
||||
...(input.context?.pr ? { pr: input.context.pr } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function baseData(input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
semanticType?: string;
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
semanticType: input.semanticType,
|
||||
subject: buildSubject(input),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args),
|
||||
createProjectObserver: (...args: unknown[]) => mockCreateProjectObserver(...args),
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args),
|
||||
resolveNotifierTarget: (
|
||||
config: { notifiers?: Record<string, { plugin?: string }> },
|
||||
reference: string,
|
||||
) => ({
|
||||
reference,
|
||||
pluginName: config.notifiers?.[reference]?.plugin ?? reference,
|
||||
}),
|
||||
buildCIFailureNotificationData: (input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
failedChecks: Array<Record<string, unknown>>;
|
||||
}) => ({
|
||||
...baseData({ ...input, semanticType: "ci.failing" }),
|
||||
ci: { status: "failing", failedChecks: input.failedChecks },
|
||||
}),
|
||||
buildPRStateNotificationData: (input: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
oldPRState: string;
|
||||
newPRState: string;
|
||||
}) => ({
|
||||
...baseData({ ...input, semanticType: input.eventType }),
|
||||
transition: { kind: "pr_state", from: input.oldPRState, to: input.newPRState },
|
||||
}),
|
||||
buildReactionNotificationData: (input: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
reactionKey: string;
|
||||
action: string;
|
||||
}) => ({
|
||||
...baseData({
|
||||
...input,
|
||||
semanticType:
|
||||
input.reactionKey === "all-complete" ? "summary.all_complete" : input.eventType,
|
||||
}),
|
||||
reaction: { key: input.reactionKey, action: input.action },
|
||||
}),
|
||||
buildSessionTransitionNotificationData: (input: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
}) => ({
|
||||
...baseData({ ...input, semanticType: input.eventType }),
|
||||
transition: { kind: "session_status", from: input.oldStatus, to: input.newStatus },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/plugin-store.js", () => ({
|
||||
importPluginModuleFromSource: vi.fn(),
|
||||
}));
|
||||
|
||||
import { registerNotify } from "../../src/commands/notify.js";
|
||||
|
||||
function makeConfig() {
|
||||
return {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts"],
|
||||
},
|
||||
projects: {
|
||||
demo: {
|
||||
name: "Demo",
|
||||
path: "/tmp/demo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "demo",
|
||||
},
|
||||
},
|
||||
notifiers: {
|
||||
alerts: { plugin: "slack" },
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["alerts"],
|
||||
warning: ["alerts"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerNotify(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("notify command", () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
|
||||
mockFindConfigFile.mockReset();
|
||||
mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml");
|
||||
mockLoadConfig.mockReset();
|
||||
mockLoadConfig.mockReturnValue(makeConfig());
|
||||
mockCreatePluginRegistry.mockReset();
|
||||
mockCreatePluginRegistry.mockReturnValue(mockRegistry);
|
||||
mockCreateProjectObserver.mockReset();
|
||||
mockCreateProjectObserver.mockReturnValue({
|
||||
recordOperation: vi.fn(),
|
||||
setHealth: vi.fn(),
|
||||
});
|
||||
mockRecordNotificationDelivery.mockReset();
|
||||
mockRegistry.loadFromConfig.mockReset();
|
||||
mockRegistry.loadFromConfig.mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockReset();
|
||||
mockRegistry.list.mockReset();
|
||||
mockRegistry.register.mockReset();
|
||||
mockRegistry.loadBuiltins.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves a dry run without sending", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockReturnValue({ name: "alerts", notify });
|
||||
|
||||
await createProgram().parseAsync(["node", "test", "notify", "test", "--dry-run"]);
|
||||
|
||||
expect(mockRegistry.loadFromConfig).toHaveBeenCalledWith(makeConfig(), expect.any(Function));
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("Dry run");
|
||||
});
|
||||
|
||||
it("sends template data and valid --data overrides", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockReturnValue({ name: "alerts", notify });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"notify",
|
||||
"test",
|
||||
"--template",
|
||||
"ci-failing",
|
||||
"--data",
|
||||
'{"runId":"123"}',
|
||||
]);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify.mock.calls[0][0]).toMatchObject({
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
data: {
|
||||
schemaVersion: 3,
|
||||
subject: {
|
||||
pr: {
|
||||
number: 1579,
|
||||
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
status: "failing",
|
||||
failedChecks: [
|
||||
{ name: "typecheck", status: "failed" },
|
||||
{ name: "unit-tests", status: "failed" },
|
||||
],
|
||||
},
|
||||
runId: "123",
|
||||
},
|
||||
});
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits 1 for invalid --data JSON", async () => {
|
||||
await expect(
|
||||
createProgram().parseAsync(["node", "test", "notify", "test", "--data", "{bad"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain(
|
||||
"Invalid --data JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures one sink webhook payload and closes cleanly", async () => {
|
||||
let sinkUrl = "";
|
||||
|
||||
mockRegistry.loadFromConfig.mockImplementation(
|
||||
(config: { notifiers: Record<string, { url?: string }> }) => {
|
||||
sinkUrl = config.notifiers.sink?.url ?? "";
|
||||
},
|
||||
);
|
||||
mockRegistry.get.mockImplementation((slot: string, name: string) => {
|
||||
if (slot !== "notifier" || name !== "sink") return null;
|
||||
return {
|
||||
name: "sink",
|
||||
notify: async (event: unknown) => {
|
||||
await fetch(sinkUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "notification", event }),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(["node", "test", "notify", "test", "--sink"]);
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
expect(output).toContain("Sink received");
|
||||
expect(output).toContain("Test notification from ao notify test");
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
|
||||
await expect(fetch(sinkUrl, { method: "POST", body: "{}" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not start a sink delivery in dry-run mode", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "notifier" && name === "sink") {
|
||||
return { name: "sink", notify };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(["node", "test", "notify", "test", "--sink", "--dry-run"]);
|
||||
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n")).not.toContain(
|
||||
"Sink received",
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ vi.mock("@aoagents/ao-core", () => ({
|
|||
isPortfolioEnabled: () => true,
|
||||
getPortfolio: mockGetPortfolio,
|
||||
getPortfolioSessionCounts: mockGetPortfolioSessionCounts,
|
||||
recordActivityEvent: vi.fn(),
|
||||
registerProject: mockRegisterProject,
|
||||
unregisterProject: mockUnregisterProject,
|
||||
loadPreferences: mockLoadPreferences,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createActivitySignal,
|
||||
createInitialCanonicalLifecycle,
|
||||
createCodeReviewStore,
|
||||
type OrchestratorConfig,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
} from "@aoagents/ao-core";
|
||||
import type * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockConfigRef, mockSessionManager, reviewStoreRootRef } = vi.hoisted(() => ({
|
||||
mockConfigRef: { current: null as OrchestratorConfig | null },
|
||||
mockSessionManager: {
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
ensureOrchestrator: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
reviewStoreRootRef: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
const createIsolatedStore = (projectId: string, options: AoCore.CodeReviewStoreOptions = {}) =>
|
||||
actual.createCodeReviewStore(projectId, {
|
||||
...options,
|
||||
storeDir: options.storeDir ?? `${reviewStoreRootRef.current}/${projectId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createCodeReviewStore: createIsolatedStore,
|
||||
triggerCodeReviewForSession: (
|
||||
options: AoCore.TriggerCodeReviewOptions,
|
||||
input: AoCore.TriggerCodeReviewInput,
|
||||
) =>
|
||||
actual.triggerCodeReviewForSession(
|
||||
{
|
||||
...options,
|
||||
storeFactory: options.storeFactory ?? createIsolatedStore,
|
||||
},
|
||||
input,
|
||||
),
|
||||
executeCodeReviewRun: (
|
||||
options: AoCore.ExecuteCodeReviewRunOptions,
|
||||
input: AoCore.ExecuteCodeReviewRunInput,
|
||||
) =>
|
||||
actual.executeCodeReviewRun(
|
||||
{
|
||||
...options,
|
||||
storeFactory: options.storeFactory ?? createIsolatedStore,
|
||||
},
|
||||
input,
|
||||
),
|
||||
sendCodeReviewFindingsToAgent: (
|
||||
options: AoCore.SendCodeReviewFindingsOptions,
|
||||
input: AoCore.SendCodeReviewFindingsInput,
|
||||
) =>
|
||||
actual.sendCodeReviewFindingsToAgent(
|
||||
{
|
||||
...options,
|
||||
storeFactory: options.storeFactory ?? createIsolatedStore,
|
||||
},
|
||||
input,
|
||||
),
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
|
||||
}));
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z"));
|
||||
lifecycle.session.state = "idle";
|
||||
lifecycle.session.reason = "awaiting_external_review";
|
||||
lifecycle.pr.state = "open";
|
||||
lifecycle.pr.reason = "review_pending";
|
||||
lifecycle.pr.number = 7;
|
||||
lifecycle.pr.url = "https://github.com/acme/app/pull/7";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/todos",
|
||||
issueId: null,
|
||||
pr: {
|
||||
number: 7,
|
||||
url: "https://github.com/acme/app/pull/7",
|
||||
title: "feat: todos",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/todos",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date("2026-05-10T09:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGitRepo(path: string): void {
|
||||
mkdirSync(path, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path });
|
||||
writeFileSync(join(path, "README.md"), "# App\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: path });
|
||||
execFileSync("git", ["commit", "-m", "initial"], {
|
||||
cwd: path,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "AO Test",
|
||||
GIT_AUTHOR_EMAIL: "ao@example.com",
|
||||
GIT_COMMITTER_NAME: "AO Test",
|
||||
GIT_COMMITTER_EMAIL: "ao@example.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let originalHome: string | undefined;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerReview } from "../../src/commands/review.js";
|
||||
|
||||
let program: Command;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-cli-review-test-"));
|
||||
reviewStoreRootRef.current = join(tmpDir, "review-store");
|
||||
originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
|
||||
mockConfigRef.current = {
|
||||
configPath: join(tmpDir, "agent-orchestrator.yaml"),
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
app: {
|
||||
name: "App",
|
||||
path: join(tmpDir, "app"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
docs: {
|
||||
name: "Docs",
|
||||
path: join(tmpDir, "docs"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "docs",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
reactions: {},
|
||||
};
|
||||
|
||||
const appPath = join(tmpDir, "app");
|
||||
createGitRepo(appPath);
|
||||
createGitRepo(join(tmpDir, "docs"));
|
||||
|
||||
mockSessionManager.get.mockReset();
|
||||
mockSessionManager.get.mockResolvedValue(makeSession({ workspacePath: appPath }));
|
||||
mockSessionManager.list.mockReset();
|
||||
mockSessionManager.send.mockReset();
|
||||
|
||||
createCodeReviewStore("app").deleteAll();
|
||||
createCodeReviewStore("docs").deleteAll();
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerReview(program);
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("review command", () => {
|
||||
it("requests and lists review runs through the CLI", async () => {
|
||||
await program.parseAsync(["node", "test", "review", "run", "app-1", "--json"]);
|
||||
|
||||
const runPayload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { linkedSessionId: string; reviewerSessionId: string; status: string };
|
||||
};
|
||||
expect(runPayload.run).toMatchObject({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
consoleLogSpy.mockClear();
|
||||
await program.parseAsync(["node", "test", "review", "list", "app", "--json"]);
|
||||
|
||||
const listPayload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
runs: Array<{ linkedSessionId: string; reviewerSessionId: string }>;
|
||||
};
|
||||
expect(listPayload.runs).toHaveLength(1);
|
||||
expect(listPayload.runs[0]).toMatchObject({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown review run statuses", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "review", "run", "app-1", "--status", "bogus"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
});
|
||||
|
||||
it("can request and execute a review run directly", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"review",
|
||||
"run",
|
||||
"app-1",
|
||||
"--execute",
|
||||
"--command",
|
||||
`printf '%s\\n' '{"findings":[{"severity":"warning","title":"CLI finding","body":"Detected in CLI."}]}'`,
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { status: string; findingCount: number; openFindingCount: number };
|
||||
};
|
||||
expect(payload.run).toMatchObject({
|
||||
status: "needs_triage",
|
||||
findingCount: 1,
|
||||
openFindingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("executes the oldest queued review run across all projects when no run is specified", async () => {
|
||||
const appStore = createCodeReviewStore("app");
|
||||
const docsStore = createCodeReviewStore("docs");
|
||||
const appPath = join(tmpDir, "app");
|
||||
const docsPath = join(tmpDir, "docs");
|
||||
mockSessionManager.get.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "docs-1") {
|
||||
return makeSession({ id: "docs-1", projectId: "docs", workspacePath: docsPath });
|
||||
}
|
||||
if (sessionId === "app-1") {
|
||||
return makeSession({ id: "app-1", projectId: "app", workspacePath: appPath });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const older = docsStore.createRun(
|
||||
{
|
||||
linkedSessionId: "docs-1",
|
||||
reviewerSessionId: "docs-rev-1",
|
||||
status: "queued",
|
||||
},
|
||||
new Date("2026-05-10T10:00:00.000Z"),
|
||||
);
|
||||
const newer = appStore.createRun(
|
||||
{
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
},
|
||||
new Date("2026-05-10T11:00:00.000Z"),
|
||||
);
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"review",
|
||||
"execute",
|
||||
"--command",
|
||||
`printf '%s\\n' '{"findings":[]}'`,
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { id: string; reviewerSessionId: string; status: string };
|
||||
};
|
||||
expect(payload.run).toMatchObject({
|
||||
id: older.id,
|
||||
reviewerSessionId: "docs-rev-1",
|
||||
status: "clean",
|
||||
});
|
||||
expect(createCodeReviewStore("app").getRun(newer.id)?.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("sends open review findings to the linked coding worker", async () => {
|
||||
const store = createCodeReviewStore("app");
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
prNumber: 7,
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "warning",
|
||||
title: "CLI finding",
|
||||
body: "Detected in CLI.",
|
||||
filePath: "src/app.ts",
|
||||
startLine: 12,
|
||||
});
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"review",
|
||||
"send",
|
||||
"app-rev-1",
|
||||
"--project",
|
||||
"app",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { status: string; openFindingCount: number; sentFindingCount: number };
|
||||
sentFindingCount: number;
|
||||
message: string;
|
||||
};
|
||||
expect(payload).toMatchObject({
|
||||
sentFindingCount: 1,
|
||||
run: {
|
||||
status: "waiting_update",
|
||||
openFindingCount: 0,
|
||||
sentFindingCount: 1,
|
||||
},
|
||||
});
|
||||
expect(payload.message).toContain("AO reviewer app-rev-1 found 1 open issue");
|
||||
expect(payload.message).toContain("Location: src/app.ts:12");
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
expect.stringContaining("[warning] CLI finding"),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,25 +2,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { type Session, type SessionManager, getProjectBaseDir } from "@aoagents/ao-core";
|
||||
import {
|
||||
recordActivityEvent,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
getProjectBaseDir,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(
|
||||
() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
tmux: vi.fn(),
|
||||
|
|
@ -49,6 +52,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -86,6 +90,9 @@ import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js";
|
|||
let program: Command;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-spawn-test-"));
|
||||
configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
|
|
@ -134,6 +141,7 @@ beforeEach(() => {
|
|||
mockSessionManager.claimPR.mockReset();
|
||||
mockExec.mockReset();
|
||||
mockGetRunning.mockReset();
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockRegistryGet.mockReset().mockReturnValue(null);
|
||||
mockGetRunning.mockResolvedValue({ pid: 1234, port: 3000, startedAt: "", projects: ["my-app"] });
|
||||
});
|
||||
|
|
@ -534,9 +542,7 @@ describe("spawn command", () => {
|
|||
it("reports error when spawn fails", async () => {
|
||||
mockSessionManager.spawn.mockRejectedValue(new Error("worktree creation failed"));
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
|
||||
});
|
||||
|
||||
it("claims a PR for the spawned session when --claim-pr is provided", async () => {
|
||||
|
|
@ -628,14 +634,7 @@ describe("spawn command", () => {
|
|||
takenOverFrom: ["app-9"],
|
||||
});
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"spawn",
|
||||
"--claim-pr",
|
||||
"123",
|
||||
"--assign-on-github",
|
||||
]);
|
||||
await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123", "--assign-on-github"]);
|
||||
|
||||
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
|
||||
assignOnGithub: true,
|
||||
|
|
@ -736,6 +735,19 @@ describe("spawn pre-flight checks", () => {
|
|||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("tmux is not installed");
|
||||
expect(recordedEvents()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.spawn_failed",
|
||||
source: "cli",
|
||||
projectId: "my-app",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
issueId: null,
|
||||
agent: null,
|
||||
errorMessage: "tmux is not installed. Install it: brew install tmux",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -860,7 +872,9 @@ describe("batch-spawn command", () => {
|
|||
return cmd;
|
||||
}
|
||||
|
||||
function makeFakeSession(overrides: Partial<Session> & Pick<Session, "id" | "projectId">): Session {
|
||||
function makeFakeSession(
|
||||
overrides: Partial<Session> & Pick<Session, "id" | "projectId">,
|
||||
): Session {
|
||||
return {
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,571 @@
|
|||
/**
|
||||
* Tests for start.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Covers MUST emits in registerStop and the start action that don't
|
||||
* require running the full startup pipeline:
|
||||
* - cli.stop_invoked (start of ao stop action)
|
||||
* - cli.stop_failed (outer catch of ao stop action)
|
||||
* - cli.stop_session_failed (per-session kill failure during ao stop)
|
||||
* - cli.last_stop_write_failed (last-stop persistence failure during ao stop)
|
||||
* - cli.daemon_killed (SIGTERM sent to parent ao start)
|
||||
* - cli.start_invoked (true start action entry)
|
||||
* - cli.start_failed (outer) (outer catch of ao start action)
|
||||
* - cli.restore_session_failed (per-session restore failure)
|
||||
*
|
||||
* cli.start_failed (orchestrator_setup / supervisor_start) is exercised by
|
||||
* the existing start.test.ts infrastructure; this file
|
||||
* focuses on emits that are reachable with a small deps surface.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockSessionManager,
|
||||
mockGetRunning,
|
||||
mockUnregister,
|
||||
mockWriteLastStop,
|
||||
mockReadLastStop,
|
||||
mockClearLastStop,
|
||||
mockAcquireStartupLock,
|
||||
mockIsAlreadyRunning,
|
||||
mockFindPidByPort,
|
||||
mockKillProcessTree,
|
||||
mockIsWindows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
ensureOrchestrator: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
mockWriteLastStop: vi.fn(),
|
||||
mockReadLastStop: vi.fn(),
|
||||
mockClearLastStop: vi.fn(),
|
||||
mockAcquireStartupLock: vi.fn(),
|
||||
mockIsAlreadyRunning: vi.fn(),
|
||||
mockFindPidByPort: vi.fn(),
|
||||
mockKillProcessTree: vi.fn(),
|
||||
mockIsWindows: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
getPluginRegistry: async () => ({ register: vi.fn(), get: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
acquireStartupLock: (...args: unknown[]) => mockAcquireStartupLock(...args),
|
||||
isAlreadyRunning: (...args: unknown[]) => mockIsAlreadyRunning(...args),
|
||||
getRunning: (...args: unknown[]) => mockGetRunning(...args),
|
||||
register: vi.fn(),
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
removeProjectFromRunning: vi.fn(),
|
||||
addProjectToRunning: vi.fn(),
|
||||
writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args),
|
||||
readLastStop: (...args: unknown[]) => mockReadLastStop(...args),
|
||||
clearLastStop: (...args: unknown[]) => mockClearLastStop(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/lifecycle-service.js", () => ({
|
||||
stopAllLifecycleWorkers: vi.fn(),
|
||||
listLifecycleWorkers: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-supervisor.js", () => ({
|
||||
startProjectSupervisor: vi.fn(),
|
||||
stopProjectSupervisor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/preflight.js", () => ({
|
||||
preflight: { checkPort: vi.fn(), checkBuilt: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
findWebDir: vi.fn().mockReturnValue("/fake/web"),
|
||||
buildDashboardEnv: vi.fn().mockResolvedValue({}),
|
||||
waitForPortAndOpen: vi.fn(),
|
||||
openUrl: vi.fn(),
|
||||
isPortAvailable: vi.fn().mockResolvedValue(true),
|
||||
findFreePort: vi.fn().mockResolvedValue(3000),
|
||||
MAX_PORT_SCAN: 100,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
|
||||
clearStaleCacheIfNeeded: vi.fn(),
|
||||
rebuildDashboardProductionArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
exec: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
execSilent: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
git: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({
|
||||
startBunTmpJanitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/daemon.js", () => ({
|
||||
attachToDaemon: vi.fn(),
|
||||
killExistingDaemon: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/caller-context.js", () => ({
|
||||
isHumanCaller: () => false,
|
||||
getCallerType: () => "automation",
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/detect-env.js", () => ({
|
||||
detectEnvironment: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/detect-agent.js", () => ({
|
||||
detectAgentRuntime: vi.fn(),
|
||||
detectAvailableAgents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/git-utils.js", () => ({
|
||||
detectDefaultBranch: vi.fn().mockResolvedValue("main"),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/prompts.js", () => ({
|
||||
promptConfirm: vi.fn().mockResolvedValue(false),
|
||||
promptSelect: vi.fn(),
|
||||
promptText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/install-helpers.js", () => ({
|
||||
canPromptForInstall: vi.fn().mockReturnValue(false),
|
||||
genericInstallHints: vi.fn().mockReturnValue([]),
|
||||
askYesNo: vi.fn().mockResolvedValue(false),
|
||||
runInteractiveCommand: vi.fn(),
|
||||
tryInstallWithAttempts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/startup-preflight.js", () => ({
|
||||
ensureGit: vi.fn(),
|
||||
runtimePreflight: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shutdown.js", () => ({
|
||||
installShutdownHandlers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/resolve-project.js", () => ({
|
||||
resolveOrCreateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-resolution.js", () => ({
|
||||
findProjectForDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/repo-utils.js", () => ({
|
||||
extractOwnerRepo: vi.fn(),
|
||||
isValidRepoString: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-detection.js", () => ({
|
||||
detectProjectType: vi.fn(),
|
||||
generateRulesFromTemplates: vi.fn(),
|
||||
formatProjectTypeForDisplay: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/cli-errors.js", () => ({
|
||||
formatCommandError: vi.fn((err: unknown) => String(err)),
|
||||
}));
|
||||
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
import { registerStart, registerStop } from "../../src/commands/start.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("ao stop — activity events", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockGetRunning.mockReset();
|
||||
mockSessionManager.list.mockReset();
|
||||
mockSessionManager.kill.mockReset();
|
||||
mockUnregister.mockReset();
|
||||
mockWriteLastStop.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockFindPidByPort.mockReset();
|
||||
mockFindPidByPort.mockResolvedValue(null);
|
||||
mockKillProcessTree.mockReset();
|
||||
mockKillProcessTree.mockResolvedValue(undefined);
|
||||
mockIsWindows.mockReset();
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits cli.stop_invoked at the start of the action", async () => {
|
||||
const projectArg = "https://token@example.com/org/repo.git";
|
||||
// Force a fast failure so the action exits quickly after emitting stop_invoked.
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
// Make loadConfig throw so we hit the outer catch
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => {
|
||||
throw new Error("config not found");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop", projectArg])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_invoked",
|
||||
source: "cli",
|
||||
summary: "ao stop invoked",
|
||||
data: expect.objectContaining({
|
||||
projectArg,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.stop_failed when loadConfig throws", async () => {
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => {
|
||||
throw new Error("config blew up");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ errorMessage: "config blew up" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.daemon_killed when SIGTERM is sent to a running daemon", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "stop"]);
|
||||
} catch {
|
||||
// ao stop may exit; we just want the events
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.daemon_killed",
|
||||
source: "cli",
|
||||
data: expect.objectContaining({ pid: 99999 }),
|
||||
}),
|
||||
);
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(99999, "SIGTERM");
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.stop_session_failed when sm.kill throws during ao stop", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockSessionManager.kill.mockRejectedValue(new Error("kill timeout"));
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
// isTerminalSession returns false so the session is treated as active
|
||||
isTerminalSession: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "stop"]);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_session_failed",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
sessionId: "sess-1",
|
||||
data: expect.objectContaining({ errorMessage: "kill timeout" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.last_stop_write_failed when ao stop cannot persist restore state", async () => {
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
|
||||
mockWriteLastStop.mockRejectedValue(new Error("last-stop lock busy"));
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
// isTerminalSession returns false so the session is treated as active
|
||||
isTerminalSession: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await program.parseAsync(["node", "ao", "stop", "my-app"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.last_stop_write_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
projectId: "my-app",
|
||||
data: expect.objectContaining({
|
||||
targetSessionCount: 1,
|
||||
totalKilled: 1,
|
||||
errorMessage: "last-stop lock busy",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.last_stop_written",
|
||||
}),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_failed",
|
||||
}),
|
||||
);
|
||||
const logs = vi.mocked(console.log).mock.calls.map((c) => String(c[0]));
|
||||
expect(logs.some((line) => line.includes("Could not list sessions"))).toBe(false);
|
||||
expect(logs.some((line) => line.includes("Could not write last-stop state"))).toBe(true);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ao start — activity events (failure paths)", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockAcquireStartupLock.mockReset();
|
||||
mockIsAlreadyRunning.mockReset();
|
||||
mockAcquireStartupLock.mockResolvedValue(() => undefined);
|
||||
mockIsAlreadyRunning.mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits cli.start_failed with reason 'outer' when resolveOrCreateProject throws", async () => {
|
||||
const projectArg = "https://token@example.com/org/repo.git";
|
||||
const resolveProjectMod = await import("../../src/lib/resolve-project.js");
|
||||
vi.mocked(resolveProjectMod.resolveOrCreateProject).mockRejectedValue(
|
||||
new Error("project resolution exploded"),
|
||||
);
|
||||
|
||||
const program = buildProgram();
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "start", projectArg]);
|
||||
} catch {
|
||||
// process.exit(1) throws in the spy
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.start_invoked",
|
||||
source: "cli",
|
||||
level: "info",
|
||||
summary: "ao start invoked",
|
||||
data: expect.objectContaining({
|
||||
projectArg,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.start_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
reason: "outer",
|
||||
errorMessage: "project resolution exploded",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { SessionManager } from "@aoagents/ao-core";
|
||||
import { recordActivityEvent, type SessionManager } from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
|
|
@ -153,6 +153,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
sweepDaemonChildren: mockSweepDaemonChildren,
|
||||
scanAoOrphans: mockScanAoOrphans,
|
||||
reapAoOrphans: mockReapAoOrphans,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -336,6 +337,7 @@ beforeEach(async () => {
|
|||
program.exitOverride();
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
|
@ -505,6 +507,9 @@ function makeProject(overrides: Record<string, unknown> = {}): Record<string, un
|
|||
};
|
||||
}
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
/** Mock process.cwd() to return a specific directory (avoids process.chdir in workers). */
|
||||
function mockCwd(dir: string): void {
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir);
|
||||
|
|
@ -1571,6 +1576,19 @@ describe("start command — orchestrator session strategy display", () => {
|
|||
await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(releaseStartupLock).toHaveBeenCalledTimes(1);
|
||||
const startFailedEvents = recordedEvents().filter((e) => e.kind === "cli.start_failed");
|
||||
expect(startFailedEvents).toHaveLength(1);
|
||||
expect(startFailedEvents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "my-app",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
reason: "orchestrator_setup",
|
||||
errorMessage: "Spawn failed",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails and cleans up dashboard when sm.restore throws on a killed orchestrator", async () => {
|
||||
|
|
@ -1651,6 +1669,56 @@ describe("start command — orchestrator session strategy display", () => {
|
|||
expect(written.stoppedAt).toBe("2026-04-28T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("attributes other-project restore failures to the owning project", async () => {
|
||||
mockReadLastStop.mockResolvedValue({
|
||||
stoppedAt: "2026-04-28T10:00:00.000Z",
|
||||
projectId: "my-app",
|
||||
sessionIds: ["app-1"],
|
||||
otherProjects: [{ projectId: "other-app", sessionIds: ["other-1"] }],
|
||||
});
|
||||
|
||||
mockConfigRef.current = makeConfig({
|
||||
"my-app": makeProject(),
|
||||
"other-app": makeProject({ name: "Other App", sessionPrefix: "other" }),
|
||||
});
|
||||
const { findWebDir } = await import("../../src/lib/web-dir.js");
|
||||
vi.mocked(findWebDir).mockReturnValue(tmpDir);
|
||||
writeFileSync(join(tmpDir, "package.json"), "{}");
|
||||
|
||||
const fakeDashboard = { on: vi.fn(), kill: vi.fn(), emit: vi.fn() };
|
||||
mockSpawn.mockReturnValue(fakeDashboard);
|
||||
|
||||
mockSessionManager.restore.mockImplementation((id: string) => {
|
||||
if (id === "other-1") return Promise.reject(new Error("workspace gone"));
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "my-app", "--no-orchestrator"]);
|
||||
|
||||
const restoreFailedEvents = recordedEvents().filter(
|
||||
(e) => e.kind === "cli.restore_session_failed",
|
||||
);
|
||||
expect(restoreFailedEvents).toHaveLength(1);
|
||||
expect(restoreFailedEvents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "other-app",
|
||||
sessionId: "other-1",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({ errorMessage: "workspace gone" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const written = mockWriteLastStop.mock.calls[0][0];
|
||||
expect(written).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "my-app",
|
||||
sessionIds: [],
|
||||
otherProjects: [{ projectId: "other-app", sessionIds: ["other-1"] }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears last-stop record when every session restored successfully", async () => {
|
||||
mockReadLastStop.mockResolvedValue({
|
||||
stoppedAt: "2026-04-28T10:00:00.000Z",
|
||||
|
|
@ -1742,6 +1810,46 @@ describe("stop command", () => {
|
|||
expect(output).toContain("app-orchestrator-3");
|
||||
});
|
||||
|
||||
it("does not show the project picker for no-args ao stop across multiple projects", async () => {
|
||||
mockConfigRef.current = makeConfig({
|
||||
"project-1": makeProject({ name: "Project 1", sessionPrefix: "p1" }),
|
||||
"project-2": makeProject({ name: "Project 2", sessionPrefix: "p2" }),
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
{
|
||||
id: "p1-1",
|
||||
projectId: "project-1",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
metadata: {},
|
||||
lastActivityAt: new Date(),
|
||||
runtimeHandle: { id: "tmux-1" },
|
||||
},
|
||||
{
|
||||
id: "p2-1",
|
||||
projectId: "project-2",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
metadata: {},
|
||||
lastActivityAt: new Date(),
|
||||
runtimeHandle: { id: "tmux-2" },
|
||||
},
|
||||
]);
|
||||
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
|
||||
await program.parseAsync(["node", "test", "stop"]);
|
||||
|
||||
expect(mockPromptSelect).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Choose project to stop"),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith("Stop AO and 2 active session(s)?", false);
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("p1-1", { purgeOpenCode: false });
|
||||
expect(mockSessionManager.kill).toHaveBeenCalledWith("p2-1", { purgeOpenCode: false });
|
||||
});
|
||||
|
||||
it("kills the most-recently-active orchestrator when multiple exist", async () => {
|
||||
mockConfigRef.current = makeConfig({ "my-app": makeProject() });
|
||||
const now = Date.now();
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import {
|
|||
type ActivityState,
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
createCodeReviewStore,
|
||||
sessionFromMetadata,
|
||||
} from "@aoagents/ao-core";
|
||||
import type * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const {
|
||||
mockTmux,
|
||||
|
|
@ -32,6 +34,7 @@ const {
|
|||
mockSessionManager,
|
||||
mockGetPluginRegistry,
|
||||
sessionsDirRef,
|
||||
reviewStoreRootRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
mockGit: vi.fn(),
|
||||
|
|
@ -54,6 +57,7 @@ const {
|
|||
},
|
||||
mockGetPluginRegistry: vi.fn(),
|
||||
sessionsDirRef: { current: "" },
|
||||
reviewStoreRootRef: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
|
|
@ -76,10 +80,17 @@ vi.mock("../../src/lib/shell.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
createCodeReviewStore: (
|
||||
projectId: string,
|
||||
options: AoCore.CodeReviewStoreOptions = {},
|
||||
) =>
|
||||
actual.createCodeReviewStore(projectId, {
|
||||
...options,
|
||||
storeDir: options.storeDir ?? `${reviewStoreRootRef.current}/${projectId}`,
|
||||
}),
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
};
|
||||
});
|
||||
|
|
@ -211,6 +222,7 @@ vi.mock("../../src/lib/create-session-manager.js", () => ({
|
|||
|
||||
let tmpDir: string;
|
||||
let sessionsDir: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerStatus } from "../../src/commands/status.js";
|
||||
|
|
@ -223,6 +235,8 @@ let processOnceSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-"));
|
||||
originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "projects: {}");
|
||||
|
|
@ -256,6 +270,7 @@ beforeEach(() => {
|
|||
sessionsDir = join(tmpDir, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
sessionsDirRef.current = sessionsDir;
|
||||
reviewStoreRootRef.current = join(tmpDir, "code-reviews");
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
|
|
@ -296,6 +311,11 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
setIntervalSpy?.mockRestore();
|
||||
setIntervalSpy = undefined;
|
||||
clearIntervalSpy?.mockRestore();
|
||||
|
|
@ -326,6 +346,67 @@ describe("status command", () => {
|
|||
expect(output).toContain("no active sessions");
|
||||
});
|
||||
|
||||
it("shows AO-local reviewer runs separately from coding sessions", async () => {
|
||||
const store = createCodeReviewStore("my-app");
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
summary: "Review run",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "warning",
|
||||
title: "Review finding",
|
||||
body: "Finding body",
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("Reviews:");
|
||||
expect(output).toContain("app-rev-1");
|
||||
expect(output).toContain("needs_triage");
|
||||
expect(output).toContain("app-1");
|
||||
expect(output).toContain("1 open finding");
|
||||
});
|
||||
|
||||
it("includes reviewer runs in JSON status output", async () => {
|
||||
const store = createCodeReviewStore("my-app");
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "warning",
|
||||
title: "Review finding",
|
||||
body: "Finding body",
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--json"]);
|
||||
|
||||
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
|
||||
const parsed = JSON.parse(jsonCalls) as {
|
||||
reviews: Array<{ reviewerSessionId: string; linkedSessionId: string; openFindingCount: number }>;
|
||||
meta: { reviewRunCount: number; activeReviewRunCount: number; openReviewFindingCount: number };
|
||||
};
|
||||
expect(parsed.reviews).toHaveLength(1);
|
||||
expect(parsed.reviews[0]).toMatchObject({
|
||||
reviewerSessionId: "app-rev-1",
|
||||
linkedSessionId: "app-1",
|
||||
openFindingCount: 1,
|
||||
});
|
||||
expect(parsed.meta).toMatchObject({
|
||||
reviewRunCount: 1,
|
||||
activeReviewRunCount: 1,
|
||||
openReviewFindingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("displays sessions from tmux with metadata", async () => {
|
||||
// Create metadata files
|
||||
writeFileSync(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Tests for update.ts activity-event instrumentation (issue #1654).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockRunRepoScript } = vi.hoisted(() => ({
|
||||
mockRunRepoScript: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/script-runner.js", () => ({
|
||||
runRepoScript: (...args: unknown[]) => mockRunRepoScript(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockDetectInstallMethod,
|
||||
mockCheckForUpdate,
|
||||
mockInvalidateCache,
|
||||
mockGetCurrentVersion,
|
||||
mockGetUpdateCommand,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDetectInstallMethod: vi.fn(() => "git" as const),
|
||||
mockCheckForUpdate: vi.fn(),
|
||||
mockInvalidateCache: vi.fn(),
|
||||
mockGetCurrentVersion: vi.fn(() => "0.2.2"),
|
||||
mockGetUpdateCommand: vi.fn(() => "npm install -g @aoagents/ao@latest"),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/update-check.js", () => ({
|
||||
detectInstallMethod: () => mockDetectInstallMethod(),
|
||||
checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args),
|
||||
invalidateCache: () => mockInvalidateCache(),
|
||||
getCurrentVersion: () => mockGetCurrentVersion(),
|
||||
getUpdateCommand: (...args: unknown[]) => mockGetUpdateCommand(...args),
|
||||
readCachedUpdateInfo: vi.fn(() => undefined),
|
||||
resolveUpdateChannel: vi.fn(() => "stable"),
|
||||
}));
|
||||
|
||||
const { mockPromptConfirm } = vi.hoisted(() => ({
|
||||
mockPromptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/prompts.js", () => ({
|
||||
promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
getRunning: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual("node:child_process");
|
||||
return { ...actual, spawn: (...args: unknown[]) => mockSpawn(...args) };
|
||||
});
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
getGlobalConfigPath: () => "/tmp/__ao_update_instrumentation_no_global_config__",
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerUpdate } from "../../src/commands/update.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals): EventEmitter {
|
||||
const child = new EventEmitter();
|
||||
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("ao update — activity events", () => {
|
||||
let program: Command;
|
||||
let origStdinTTY: boolean | undefined;
|
||||
let origStdoutTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(AoCore.recordActivityEvent).mockClear();
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerUpdate(program);
|
||||
mockRunRepoScript.mockReset();
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockCheckForUpdate.mockReset();
|
||||
mockInvalidateCache.mockReset();
|
||||
mockPromptConfirm.mockReset();
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReset();
|
||||
origStdinTTY = process.stdin.isTTY;
|
||||
origStdoutTTY = process.stdout.isTTY;
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: origStdinTTY, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true });
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when ao-update.sh exits non-zero (git path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockRunRepoScript.mockResolvedValue(2);
|
||||
|
||||
// process.exit is mocked to throw — the first `process.exit(2)` triggers
|
||||
// the throw, which is then re-caught and emits a second event before the
|
||||
// final exit. The instrumentation event for the non-zero exit is what
|
||||
// matters; whichever final exit code propagates is incidental.
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(/process\.exit/);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "git", exitCode: 2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when ao-update.sh script is missing (git path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockRunRepoScript.mockRejectedValue(new Error("Script not found: ao-update.sh"));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "git", reason: "script_missing" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm install exits non-zero (npm path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue({
|
||||
currentVersion: "0.2.2",
|
||||
latestVersion: "0.3.0",
|
||||
isOutdated: true,
|
||||
installMethod: "npm-global" as const,
|
||||
recommendedCommand: "npm install -g @aoagents/ao@latest",
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
mockSpawn.mockReturnValue(createMockChild(1));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "npm-global", exitCode: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm registry lookup returns no version", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue({
|
||||
currentVersion: "0.2.2",
|
||||
latestVersion: null,
|
||||
isOutdated: false,
|
||||
installMethod: "npm-global" as const,
|
||||
recommendedCommand: "npm install -g @aoagents/ao@latest",
|
||||
checkedAt: null,
|
||||
});
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
method: "npm-global",
|
||||
reason: "registry_unreachable",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm registry lookup throws", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockRejectedValue(new Error("registry timeout"));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
method: "npm-global",
|
||||
reason: "registry_lookup_threw",
|
||||
errorMessage: "registry timeout",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,9 +5,7 @@ import { Command } from "commander";
|
|||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockRunRepoScript,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockRunRepoScript } = vi.hoisted(() => ({
|
||||
mockRunRepoScript: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -55,8 +53,8 @@ vi.mock("../../src/lib/update-check.js", () => ({
|
|||
isManualOnlyInstall: (m: string) => m === "homebrew",
|
||||
}));
|
||||
|
||||
// Stub the active-session guard's dependencies so handlers don't try to load
|
||||
// real config / spawn plugins. Default: no sessions, so the guard passes.
|
||||
// Stub the update lifecycle planner's dependencies so handlers don't try to
|
||||
// load real config / spawn plugins. Default: no sessions, so no stop is needed.
|
||||
const { mockSessions } = vi.hoisted(() => ({
|
||||
mockSessions: { value: [] as Array<{ id: string; status: string }> },
|
||||
}));
|
||||
|
|
@ -84,8 +82,7 @@ vi.mock("@aoagents/ao-core", async () => {
|
|||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args),
|
||||
getGlobalConfigPath: () => "/tmp/test-global-config.yaml",
|
||||
isCanonicalGlobalConfigPath: (p: string | undefined) =>
|
||||
p === "/tmp/test-global-config.yaml",
|
||||
isCanonicalGlobalConfigPath: (p: string | undefined) => p === "/tmp/test-global-config.yaml",
|
||||
isWindows: () => mockIsWindows(),
|
||||
};
|
||||
});
|
||||
|
|
@ -98,10 +95,8 @@ vi.mock("node:fs", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
// running.json is the live signal: ensureNoActiveSessions now consults
|
||||
// `getRunning()` before falling back to the global registry. Default to
|
||||
// "no daemon running" so the existing global-config-driven tests keep
|
||||
// exercising the fallback path. Per-test overrides simulate a live daemon.
|
||||
// running.json is the live signal for update stop/start orchestration. Default
|
||||
// to "no daemon running"; per-test overrides simulate a live daemon.
|
||||
const { mockGetRunning } = vi.hoisted(() => ({
|
||||
mockGetRunning: vi.fn<() => Promise<unknown>>(async () => null),
|
||||
}));
|
||||
|
|
@ -147,9 +142,28 @@ function makeNpmUpdateInfo(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals) {
|
||||
function createMockChild(
|
||||
exitCode: number | null,
|
||||
signal?: NodeJS.Signals,
|
||||
output: { stdout?: string; stderr?: string } = { stdout: "0.3.0\n" },
|
||||
) {
|
||||
const child = new EventEmitter();
|
||||
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
Object.assign(child, { stdout, stderr });
|
||||
const emitResult = () => {
|
||||
setTimeout(() => {
|
||||
if (output.stdout) stdout.emit("data", Buffer.from(output.stdout));
|
||||
if (output.stderr) stderr.emit("data", Buffer.from(output.stderr));
|
||||
child.emit("exit", exitCode, signal ?? null);
|
||||
}, 0);
|
||||
};
|
||||
const originalOn = child.on.bind(child);
|
||||
child.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
const result = originalOn(event, listener);
|
||||
if (event === "exit") emitResult();
|
||||
return result;
|
||||
}) as EventEmitter["on"];
|
||||
return child;
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +180,9 @@ describe("update command", () => {
|
|||
mockRunRepoScript.mockResolvedValue(0);
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockCheckForUpdate.mockReset();
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }));
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }),
|
||||
);
|
||||
mockInvalidateCache.mockReset();
|
||||
mockPromptConfirm.mockReset();
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
|
|
@ -234,9 +250,9 @@ describe("update command", () => {
|
|||
it("rejects --smoke-only on npm installs with an actionable message", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update", "--smoke-only"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update", "--smoke-only"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(messages).toMatch(/--smoke-only only applies to git installs/);
|
||||
});
|
||||
|
|
@ -306,9 +322,9 @@ describe("update command", () => {
|
|||
new Error("Script not found: ao-update.sh. Expected at: /tmp/ao-update.sh"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockCheckForUpdate).not.toHaveBeenCalled();
|
||||
|
|
@ -353,7 +369,9 @@ describe("update command", () => {
|
|||
});
|
||||
|
||||
it("prints already up to date when not outdated", async () => {
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }));
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }),
|
||||
);
|
||||
|
||||
const logSpy = vi.mocked(console.log);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
|
@ -365,9 +383,9 @@ describe("update command", () => {
|
|||
makeNpmUpdateInfo({ latestVersion: null, isOutdated: false }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
expect(vi.mocked(console.error)).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not reach npm registry"),
|
||||
);
|
||||
|
|
@ -375,9 +393,7 @@ describe("update command", () => {
|
|||
|
||||
it("forces a fresh registry fetch", async () => {
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ force: true }),
|
||||
);
|
||||
expect(mockCheckForUpdate).toHaveBeenCalledWith(expect.objectContaining({ force: true }));
|
||||
});
|
||||
|
||||
it("prints command and exits cleanly in non-TTY mode without prompting", async () => {
|
||||
|
|
@ -400,7 +416,11 @@ describe("update command", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith("npm", expect.arrayContaining(["install"]), expect.anything());
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm",
|
||||
expect.arrayContaining(["install"]),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockInvalidateCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -410,9 +430,9 @@ describe("update command", () => {
|
|||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(1));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
expect(mockInvalidateCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -438,9 +458,9 @@ describe("update command", () => {
|
|||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(null, "SIGTERM"));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
expect(vi.mocked(console.error)).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("exited with code null"),
|
||||
|
|
@ -448,18 +468,26 @@ describe("update command", () => {
|
|||
expect(mockInvalidateCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles spawn error (e.g. npm not found)", async () => {
|
||||
it("handles spawn error (e.g. npm not found) with a friendly update failure", async () => {
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
|
||||
const child = new EventEmitter();
|
||||
Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter() });
|
||||
mockSpawn.mockReturnValue(child);
|
||||
setTimeout(() => child.emit("error", new Error("ENOENT: npm not found")), 0);
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("ENOENT");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
|
||||
expect(stderr).toContain("npm not found");
|
||||
});
|
||||
|
||||
it("does nothing when user declines prompt", async () => {
|
||||
|
|
@ -489,7 +517,9 @@ describe("update command", () => {
|
|||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Could not detect install method"));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not detect install method"),
|
||||
);
|
||||
expect(mockRunRepoScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -521,269 +551,157 @@ describe("update command", () => {
|
|||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Active-session guard (Section C)
|
||||
// Update lifecycle orchestration (#1972)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("active-session guard", () => {
|
||||
describe("update lifecycle orchestration", () => {
|
||||
beforeEach(() => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
|
||||
mockDetectInstallMethod.mockReturnValue("pnpm-global");
|
||||
mockResolveUpdateChannel.mockReturnValue("stable");
|
||||
mockGetUpdateCommand.mockImplementation((method: string) => {
|
||||
if (method === "pnpm-global") return "pnpm add -g @aoagents/ao@latest";
|
||||
return "npm install -g @aoagents/ao@latest";
|
||||
});
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({
|
||||
installMethod: "pnpm-global",
|
||||
recommendedCommand: "pnpm add -g @aoagents/ao@latest",
|
||||
}),
|
||||
);
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
// The guard now ALWAYS loads from global config. Stage a registered
|
||||
// project so the early-return ("no registry → allow") doesn't fire.
|
||||
mockGetRunning
|
||||
.mockResolvedValueOnce({
|
||||
pid: 12345,
|
||||
configPath: "/tmp/test-global-config.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
})
|
||||
.mockResolvedValue(null);
|
||||
mockLoadConfig.mockImplementation((path?: string) => ({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
configPath: path ?? "/cwd/agent-orchestrator.yaml",
|
||||
}));
|
||||
mockSessions.value = [{ id: "feat-1", status: "working", projectId: "my-app" }];
|
||||
});
|
||||
|
||||
it("runs stop → package install → version verification → start/restore when sessions are active", async () => {
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(4);
|
||||
expect(mockSpawn.mock.calls[0]).toEqual([
|
||||
"ao",
|
||||
["stop", "--yes"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]);
|
||||
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
|
||||
expect(mockSpawn.mock.calls[3]).toEqual([
|
||||
"ao",
|
||||
["start", "my-app", "--restore"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const stopOrder = mockSpawn.mock.invocationCallOrder[0];
|
||||
const installOrder = mockSpawn.mock.invocationCallOrder[1];
|
||||
const startOrder = mockSpawn.mock.invocationCallOrder[3];
|
||||
expect(stopOrder).toBeLessThan(installOrder);
|
||||
expect(installOrder).toBeLessThan(startOrder);
|
||||
});
|
||||
|
||||
it("does not stop/start when no daemon or active sessions exist", async () => {
|
||||
mockGetRunning.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockSessions.value = [];
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[1][1]).toEqual(["--version"]);
|
||||
});
|
||||
|
||||
it("cleans up orphaned active sessions without starting a daemon that was not running", async () => {
|
||||
mockGetRunning.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
});
|
||||
mockLoadConfig.mockImplementation((path?: string) =>
|
||||
path
|
||||
? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path }
|
||||
: { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" },
|
||||
mockSessions.value = [{ id: "orphan-1", status: "working", projectId: "my-app" }];
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "stop") {
|
||||
mockSessions.value = [];
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
}
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
|
||||
expect(mockSpawn.mock.calls.some((call) => call[0] === "ao" && call[1][0] === "start")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to install when a session is in 'working'", async () => {
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(messages).toMatch(/1 session active/);
|
||||
expect(messages).toMatch(/ao stop/);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["working", "idle", "needs_input", "stuck"])(
|
||||
"refuses for status %s",
|
||||
async (status) => {
|
||||
mockSessions.value = [{ id: "feat-1", status }];
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
},
|
||||
);
|
||||
|
||||
it("does NOT refuse for terminal statuses (done, terminated, killed)", async () => {
|
||||
mockSessions.value = [
|
||||
{ id: "old-1", status: "done" },
|
||||
{ id: "old-2", status: "terminated" },
|
||||
];
|
||||
mockPromptConfirm.mockResolvedValue(false); // decline, no install
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
// Reaches the prompt step since the guard passed.
|
||||
expect(mockPromptConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Global-config layout (review #3 / scope-gap follow-up)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
it("the refusal message lists active sessions from EVERY registered project, not just one (Dhruv proof)", async () => {
|
||||
// Reviewer challenge: prove loadConfig(globalPath) actually enumerates
|
||||
// sessions across all registered projects, not just the cwd's project.
|
||||
// We register proj-a + proj-b in the global config, seed one active
|
||||
// session in each, and assert BOTH ids appear in the stderr output.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
// Mimic buildEffectiveConfigFromGlobalConfigPath: the global path
|
||||
// returns BOTH projects; project-local would only return one.
|
||||
if (!path) {
|
||||
return { projects: { "proj-a": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
|
||||
it("honors --no-restore when restarting after update", async () => {
|
||||
mockSpawn.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "ao" && args[0] === "--version") {
|
||||
return createMockChild(0, undefined, { stdout: "0.3.0\n" });
|
||||
}
|
||||
return {
|
||||
projects: {
|
||||
"proj-a": { path: "/repos/a" },
|
||||
"proj-b": { path: "/repos/b" },
|
||||
},
|
||||
configPath: path,
|
||||
};
|
||||
return createMockChild(0, undefined, { stdout: "" });
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: {
|
||||
"proj-a": { path: "/repos/a" },
|
||||
"proj-b": { path: "/repos/b" },
|
||||
},
|
||||
});
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
// One active session per project. sm.list() is single-call (the SM
|
||||
// implementation enumerates across all projectIds), so we return both
|
||||
// sessions in one shot — matching real behavior. `projectId` is
|
||||
// included so it's visible to anyone reading the refusal output.
|
||||
mockSessions.value = [
|
||||
{ id: "proj-a-feat-1", status: "working", projectId: "proj-a" },
|
||||
{ id: "proj-b-feat-2", status: "needs_input", projectId: "proj-b" },
|
||||
];
|
||||
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await program.parseAsync(["node", "test", "update", "--no-restore"]);
|
||||
|
||||
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
// Refusal message reports the correct total count (2, not 1).
|
||||
expect(stderr).toMatch(/2 sessions active/);
|
||||
// Both project's session ids appear in the listing.
|
||||
expect(stderr).toMatch(/proj-a-feat-1/);
|
||||
expect(stderr).toMatch(/proj-b-feat-2/);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockSpawn.mock.calls.at(-1)).toEqual([
|
||||
"ao",
|
||||
["start", "my-app", "--no-restore"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("always loads global config (never project-local), so sessions in OTHER projects fire the guard", async () => {
|
||||
// Simulate running inside a project: project-local loadConfig() would
|
||||
// succeed and return only THIS project's sessions. The guard must
|
||||
// ignore it and still consult the global registry, otherwise active
|
||||
// sessions in other projects get missed and the install would proceed.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) {
|
||||
// Project-local: would return only "this-project"'s sessions.
|
||||
return { projects: { "this-project": {} }, configPath: "/cwd/agent-orchestrator.yaml" };
|
||||
}
|
||||
return {
|
||||
projects: {
|
||||
"this-project": { path: "/cwd" },
|
||||
"other-project": { path: "/other" },
|
||||
},
|
||||
configPath: path,
|
||||
};
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: {
|
||||
"this-project": { path: "/cwd" },
|
||||
"other-project": { path: "/other" },
|
||||
},
|
||||
});
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
// Active session lives in the OTHER project — only visible via global.
|
||||
mockSessions.value = [
|
||||
{ id: "other-1", status: "working" },
|
||||
];
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(mockLoadGlobalConfig).toHaveBeenCalled();
|
||||
// Critical: we did NOT call the project-local (no-arg) loadConfig path.
|
||||
const noArgCalls = mockLoadConfig.mock.calls.filter((c) => c.length === 0);
|
||||
expect(noArgCalls).toHaveLength(0);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the global registry when running outside any project", async () => {
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) throw new Error("no config found");
|
||||
return { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path };
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
});
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(mockLoadGlobalConfig).toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early without building SessionManager when global registry is empty", async () => {
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) throw new Error("no config found");
|
||||
return { projects: {}, configPath: path };
|
||||
});
|
||||
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
// Guard returns true (allow update) without ever calling sm.list().
|
||||
// No mockSessions configured, no spawn → confirms we never reached
|
||||
// SessionManager construction.
|
||||
mockPromptConfirm.mockResolvedValue(false); // decline soft-install
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockLoadGlobalConfig).toHaveBeenCalled();
|
||||
// The decline-prompt path means the guard let us through.
|
||||
expect(mockPromptConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early without building SessionManager when global config file is missing", async () => {
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (!path) throw new Error("no config found");
|
||||
return { projects: {}, configPath: path };
|
||||
});
|
||||
mockExistsSync.mockReturnValue(false); // no ~/.agent-orchestrator/config.yaml
|
||||
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
// We didn't even consult loadGlobalConfig — existsSync(globalPath) was false.
|
||||
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
|
||||
expect(mockPromptConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)", async () => {
|
||||
// The bypass: user ran `ao start` from a repo with a local
|
||||
// agent-orchestrator.yaml and no global registration. running.json
|
||||
// says that project is being polled, sessions live on disk, but the
|
||||
// global registry is empty. Before this fix, the guard hit the
|
||||
// "global has no projects → allow" branch and let `ao update`
|
||||
// clobber the running daemon.
|
||||
//
|
||||
// Fix: consult running.json BEFORE falling back to global. When
|
||||
// running.json has projects, build the SessionManager from
|
||||
// running.configPath (which is the local project's yaml in this case)
|
||||
// and enumerate from there.
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 12345,
|
||||
configPath: "/repos/local-only/agent-orchestrator.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["local-only"],
|
||||
});
|
||||
// Global registry has no record of `local-only` — this is the bypass
|
||||
// condition. With the old code, we'd return true here.
|
||||
mockLoadGlobalConfig.mockReturnValue({ projects: {} });
|
||||
// loadConfig with the local configPath returns the local project's
|
||||
// OrchestratorConfig (project-local schema is auto-wrapped).
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/repos/local-only/agent-orchestrator.yaml") {
|
||||
return {
|
||||
projects: { "local-only": { path: "/repos/local-only" } },
|
||||
configPath: path,
|
||||
};
|
||||
}
|
||||
return { projects: {}, configPath: path ?? "/cwd/agent-orchestrator.yaml" };
|
||||
});
|
||||
mockSessions.value = [
|
||||
{ id: "local-feat-1", status: "working", projectId: "local-only" },
|
||||
];
|
||||
|
||||
const errSpy = vi.mocked(console.error);
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
||||
expect(stderr).toMatch(/1 session active/);
|
||||
expect(stderr).toMatch(/local-feat-1/);
|
||||
// We must have routed through running.configPath, NOT the global path.
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/repos/local-only/agent-orchestrator.yaml");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true (allows update) when running.json is gone and global is empty", async () => {
|
||||
// No daemon running, no global projects. Genuinely safe to update.
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockPromptConfirm.mockResolvedValue(false);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt
|
||||
});
|
||||
|
||||
it("trusts running.json over an inconsistent global config", async () => {
|
||||
// running.json says project P is being polled. Global config also
|
||||
// lists P. We should use running.configPath (the live signal), and
|
||||
// any active session in P fires the guard.
|
||||
it("aborts before install if ao stop exits 0 but AO still appears active", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 12345,
|
||||
configPath: "/tmp/test-global-config.yaml",
|
||||
|
|
@ -791,23 +709,46 @@ describe("update command", () => {
|
|||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockLoadConfig.mockImplementation((path?: string) => ({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
configPath: path ?? "/cwd/agent-orchestrator.yaml",
|
||||
}));
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
});
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" }));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
// Because getRunning() returned a daemon, we went straight to its
|
||||
// configPath — we should NOT have fallen back to loadGlobalConfig.
|
||||
expect(mockLoadGlobalConfig).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false);
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO still appears to be running after `ao stop --yes`");
|
||||
});
|
||||
|
||||
it("prints a friendly pnpm diagnostic and npm fallback when pnpm fails", async () => {
|
||||
mockGetRunning.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockSpawn.mockReturnValue(
|
||||
createMockChild(1, undefined, {
|
||||
stderr: "ERR_PNPM_UNEXPECTED_VIRTUAL_STORE Unexpected virtual store location\n",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO was not updated. You are still on version 0.2.2.");
|
||||
expect(stderr).toContain("pnpm's global store metadata is inconsistent");
|
||||
expect(stderr).toContain("You can also try: npm install -g @aoagents/ao@latest");
|
||||
expect(stderr).toContain("ERR_PNPM_UNEXPECTED_VIRTUAL_STORE");
|
||||
expect(mockInvalidateCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -874,7 +815,7 @@ describe("update command", () => {
|
|||
}),
|
||||
);
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(0));
|
||||
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
|
|
@ -990,7 +931,7 @@ describe("update command", () => {
|
|||
}),
|
||||
);
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReturnValue(createMockChild(0));
|
||||
mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" }));
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
|
|
@ -1035,9 +976,7 @@ describe("update command", () => {
|
|||
beforeEach(() => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockResolveUpdateChannel.mockReturnValue("stable");
|
||||
mockCheckForUpdate.mockResolvedValue(
|
||||
makeNpmUpdateInfo({ installMethod: "npm-global" }),
|
||||
);
|
||||
mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" }));
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
projects: { "my-app": { path: "/tmp/foo" } },
|
||||
|
|
@ -1069,7 +1008,11 @@ describe("update command", () => {
|
|||
// would run. Asserting spawn was called proves the install actually
|
||||
// happens in the API-invoked path.
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm",
|
||||
expect.arrayContaining(["install"]),
|
||||
expect.anything(),
|
||||
);
|
||||
// And without a TTY, we MUST NOT have prompted — that would hang the
|
||||
// detached child forever.
|
||||
expect(mockPromptConfirm).not.toHaveBeenCalled();
|
||||
|
|
@ -1084,12 +1027,25 @@ describe("update command", () => {
|
|||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses on active sessions even when API-invoked (the API's own guard isn't a single point of trust)", async () => {
|
||||
it("orchestrates stop/install/verify/start when active sessions exist and update is API-invoked", async () => {
|
||||
mockSessions.value = [{ id: "feat-1", status: "working" }];
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "update"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
mockGetRunning
|
||||
.mockResolvedValueOnce({
|
||||
pid: 12345,
|
||||
configPath: "/tmp/test-global-config.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
})
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls.at(-1)?.[1]).toEqual(["start", "my-app", "--restore"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1128,17 +1084,15 @@ describe("update command", () => {
|
|||
it("passes shell:true and windowsHide:true on Windows so PATHEXT resolves npm.cmd", async () => {
|
||||
mockIsWindows.mockReturnValue(true);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
|
||||
expect(opts.shell).toBe(true);
|
||||
expect(opts.windowsHide).toBe(true);
|
||||
expect(opts.stdio).toBe("inherit");
|
||||
expect(opts.stdio).toEqual(["inherit", "pipe", "pipe"]);
|
||||
});
|
||||
|
||||
it("passes shell:false on macOS / Linux (no shell wrap needed)", async () => {
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
await program.parseAsync(["node", "test", "update"]);
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||
const opts = mockSpawn.mock.calls[0][2] as Record<string, unknown>;
|
||||
expect(opts.shell).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { dashboardUrl } from "../../src/lib/dashboard-url.js";
|
||||
|
||||
describe("dashboardUrl", () => {
|
||||
const original = process.env.AO_PUBLIC_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.AO_PUBLIC_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) {
|
||||
delete process.env.AO_PUBLIC_URL;
|
||||
} else {
|
||||
process.env.AO_PUBLIC_URL = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to localhost when AO_PUBLIC_URL is unset", () => {
|
||||
expect(dashboardUrl(3000)).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("falls back to localhost when AO_PUBLIC_URL is empty", () => {
|
||||
process.env.AO_PUBLIC_URL = "";
|
||||
expect(dashboardUrl(8094)).toBe("http://localhost:8094");
|
||||
});
|
||||
|
||||
it("falls back to localhost when AO_PUBLIC_URL is whitespace only", () => {
|
||||
process.env.AO_PUBLIC_URL = " ";
|
||||
expect(dashboardUrl(8094)).toBe("http://localhost:8094");
|
||||
});
|
||||
|
||||
it("uses AO_PUBLIC_URL when set", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("ignores the port argument when AO_PUBLIC_URL is set", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
expect(dashboardUrl(8094)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("strips a trailing slash from AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com/";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("strips multiple trailing slashes from AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://ao.example.com///";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("preserves a sub-path in AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "https://example.com/ao";
|
||||
expect(dashboardUrl(3000)).toBe("https://example.com/ao");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace from AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = " https://ao.example.com ";
|
||||
expect(dashboardUrl(3000)).toBe("https://ao.example.com");
|
||||
});
|
||||
|
||||
it("supports a non-default port in AO_PUBLIC_URL", () => {
|
||||
process.env.AO_PUBLIC_URL = "http://192.168.1.5:9000";
|
||||
expect(dashboardUrl(3000)).toBe("http://192.168.1.5:9000");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeDb,
|
||||
readObservabilitySummary,
|
||||
type Notifier,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorEvent,
|
||||
type PluginRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
addSinkNotifierConfig,
|
||||
createNotifyTestEvent,
|
||||
parseNotifyDataJson,
|
||||
resolveNotifyTestTargets,
|
||||
runNotifyTest,
|
||||
startNotifySink,
|
||||
} from "../../src/lib/notify-test.js";
|
||||
|
||||
function makeConfig(overrides: Partial<OrchestratorConfig> = {}): OrchestratorConfig {
|
||||
const testHome = process.env["HOME"] ?? process.env["USERPROFILE"] ?? tmpdir();
|
||||
return {
|
||||
configPath: join(testHome, "agent-orchestrator.yaml"),
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts"],
|
||||
},
|
||||
projects: {
|
||||
demo: {
|
||||
name: "Demo",
|
||||
path: "/tmp/demo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "demo",
|
||||
},
|
||||
},
|
||||
notifiers: {
|
||||
alerts: { plugin: "slack" },
|
||||
ops: { plugin: "slack" },
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["alerts", "ops"],
|
||||
warning: ["ops"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
reactions: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(notifiers: Record<string, Partial<Notifier> | undefined>): PluginRegistry {
|
||||
return {
|
||||
register: vi.fn(),
|
||||
get: vi.fn((slot: string, name: string) => {
|
||||
if (slot !== "notifier") return null;
|
||||
return notifiers[name] ?? null;
|
||||
}),
|
||||
list: vi.fn(() => []),
|
||||
loadBuiltins: vi.fn(),
|
||||
loadFromConfig: vi.fn(),
|
||||
} as unknown as PluginRegistry;
|
||||
}
|
||||
|
||||
describe("notify test helper", () => {
|
||||
let tempRoot: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(tmpdir(), `ao-notify-test-${randomUUID()}`);
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env["USERPROFILE"];
|
||||
} else {
|
||||
process.env["USERPROFILE"] = originalUserProfile;
|
||||
}
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("builds realistic CI and PR template data", () => {
|
||||
const { event } = createNotifyTestEvent({ templateName: "ci-failing" });
|
||||
|
||||
expect(event.type).toBe("ci.failing");
|
||||
expect(event.priority).toBe("action");
|
||||
expect(event.data).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
semanticType: "ci.failing",
|
||||
subject: {
|
||||
pr: {
|
||||
number: 1579,
|
||||
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
status: "failing",
|
||||
failedChecks: [
|
||||
{ name: "typecheck", status: "failed" },
|
||||
{ name: "unit-tests", status: "failed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(event.data.prUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges valid --data JSON and rejects invalid JSON", () => {
|
||||
expect(parseNotifyDataJson('{"runId":"abc","attempt":2}')).toEqual({
|
||||
runId: "abc",
|
||||
attempt: 2,
|
||||
});
|
||||
expect(() => parseNotifyDataJson("{bad json")).toThrow("Invalid --data JSON");
|
||||
expect(() => parseNotifyDataJson('"not an object"')).toThrow("--data must be a JSON object");
|
||||
});
|
||||
|
||||
it("resolves aliases and falls back to plugin-name registry lookup", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
slack: { name: "slack", notify },
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const result = await runNotifyTest(config, registry, { to: ["alerts"] });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.targets).toEqual([{ reference: "alerts", pluginName: "slack" }]);
|
||||
expect(registry.get).toHaveBeenCalledWith("notifier", "alerts");
|
||||
expect(registry.get).toHaveBeenCalledWith("notifier", "slack");
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["demo"]?.metrics["notification_delivery"]?.success).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves explicit routes through notificationRouting before defaults", () => {
|
||||
const targets = resolveNotifyTestTargets(makeConfig(), "info", { route: "action" });
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ reference: "alerts", pluginName: "slack" },
|
||||
{ reference: "ops", pluginName: "slack" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates --all targets across configured, default, and routed refs", () => {
|
||||
const config = makeConfig({
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts", "ops"],
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["ops"],
|
||||
warning: ["alerts", "ops"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
});
|
||||
|
||||
const targets = resolveNotifyTestTargets(config, "info", { all: true });
|
||||
|
||||
expect(targets.map((target) => target.reference)).toEqual(["alerts", "ops"]);
|
||||
});
|
||||
|
||||
it("does not send in dry-run mode", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(makeConfig(), registry, { dryRun: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.deliveries[0]?.status).toBe("dry_run");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(readObservabilitySummary(makeConfig()).projects["demo"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses notifyWithActions when available", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const notifyWithActions = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify, notifyWithActions },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(makeConfig(), registry, { actions: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.deliveries[0]?.method).toBe("notifyWithActions");
|
||||
expect(notifyWithActions).toHaveBeenCalledTimes(1);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and falls back to notify when actions are unsupported", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(makeConfig(), registry, { actions: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.deliveries[0]?.method).toBe("notify");
|
||||
expect(result.warnings[0]).toContain("notifyWithActions() is unavailable");
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues after partial delivery failures", async () => {
|
||||
const failing = vi.fn().mockRejectedValue(new Error("webhook failed"));
|
||||
const passing = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify: failing },
|
||||
ops: { name: "ops", notify: passing },
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const result = await runNotifyTest(config, registry, { route: "action" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(failing).toHaveBeenCalledTimes(1);
|
||||
expect(passing).toHaveBeenCalledTimes(1);
|
||||
expect(result.deliveries.map((delivery) => delivery.status)).toEqual(["failed", "sent"]);
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["demo"]?.metrics["notification_delivery"]).toMatchObject({
|
||||
success: 1,
|
||||
failure: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unresolved targets and no-target configs as failures", async () => {
|
||||
const unresolved = await runNotifyTest(makeConfig(), makeRegistry({}), { to: ["missing"] });
|
||||
expect(unresolved.ok).toBe(false);
|
||||
expect(unresolved.deliveries[0]?.status).toBe("unresolved");
|
||||
|
||||
const noTargets = await runNotifyTest(
|
||||
makeConfig({
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: [],
|
||||
action: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
}),
|
||||
makeRegistry({}),
|
||||
);
|
||||
expect(noTargets.ok).toBe(false);
|
||||
expect(noTargets.errors[0]).toContain("No notifier targets resolved");
|
||||
});
|
||||
|
||||
it("captures a local webhook sink payload", async () => {
|
||||
const sink = await startNotifySink(0);
|
||||
try {
|
||||
const config = addSinkNotifierConfig(makeConfig({ notifiers: {} }), sink.url);
|
||||
const notify = vi.fn(async (event: OrchestratorEvent) => {
|
||||
await fetch(sink.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "notification", event }),
|
||||
});
|
||||
});
|
||||
const registry = makeRegistry({
|
||||
sink: { name: "sink", notify },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(config, registry, { to: ["sink"] });
|
||||
const request = await sink.waitForRequest();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(request?.json).toMatchObject({
|
||||
type: "notification",
|
||||
event: {
|
||||
message: "Test notification from ao notify test",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,12 @@ const mockSetHealth = vi.fn();
|
|||
const activeWorkers = new Set<string>();
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
ConfigNotFoundError: class ConfigNotFoundError extends Error {
|
||||
constructor(message = "No agent-orchestrator.yaml found.") {
|
||||
super(message);
|
||||
this.name = "ConfigNotFoundError";
|
||||
}
|
||||
},
|
||||
createCorrelationId: () => "correlation-id",
|
||||
createProjectObserver: () => ({ setHealth: (...args: unknown[]) => mockSetHealth(...args) }),
|
||||
getGlobalConfigPath: () => "/tmp/global-config.yaml",
|
||||
|
|
@ -67,9 +73,9 @@ import {
|
|||
stopProjectSupervisor,
|
||||
} from "../../src/lib/project-supervisor.js";
|
||||
|
||||
function makeConfig(projectIds: string[]) {
|
||||
function makeConfig(projectIds: string[], configPath = "/tmp/global-config.yaml") {
|
||||
return {
|
||||
configPath: "/tmp/global-config.yaml",
|
||||
configPath,
|
||||
projects: Object.fromEntries(projectIds.map((id) => [id, { name: id, path: `/tmp/${id}` }])),
|
||||
};
|
||||
}
|
||||
|
|
@ -223,7 +229,7 @@ describe("project-supervisor", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const startPromise = startProjectSupervisor(1_000);
|
||||
const startPromise = startProjectSupervisor({ intervalMs: 1_000 });
|
||||
await vi.waitFor(() => expect(releaseList).toBeDefined());
|
||||
|
||||
stopProjectSupervisor();
|
||||
|
|
@ -242,7 +248,7 @@ describe("project-supervisor", () => {
|
|||
throw new Error("bad config");
|
||||
});
|
||||
|
||||
await expect(startProjectSupervisor(1_000)).rejects.toThrow("bad config");
|
||||
await expect(startProjectSupervisor({ intervalMs: 1_000 })).rejects.toThrow("bad config");
|
||||
});
|
||||
|
||||
it("allows startup when the global config does not exist yet", async () => {
|
||||
|
|
@ -257,7 +263,7 @@ describe("project-supervisor", () => {
|
|||
throw error;
|
||||
});
|
||||
|
||||
const handle = await startProjectSupervisor(1_000);
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_000 });
|
||||
|
||||
expect(handle).toEqual({
|
||||
stop: expect.any(Function),
|
||||
|
|
@ -266,10 +272,230 @@ describe("project-supervisor", () => {
|
|||
handle.stop();
|
||||
});
|
||||
|
||||
it("falls back to local config when the global config is missing (ENOENT)", async () => {
|
||||
// The local fallback uses a DIFFERENT configPath than the global —
|
||||
// a real bare `loadConfig()` discovers the local file and sets
|
||||
// `config.configPath` to that path. Asserting on the local path here
|
||||
// catches any bug that would propagate the global path through the
|
||||
// fallback (e.g. accidentally returning the global config object).
|
||||
const localConfigPath = "/tmp/cwd/agent-orchestrator.yaml";
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
return makeConfig(["app"], localConfigPath);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor();
|
||||
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith();
|
||||
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configPath: localConfigPath }),
|
||||
"app",
|
||||
undefined,
|
||||
);
|
||||
expect(activeWorkers.has("app")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the caller-provided configPath as the local fallback when global is missing", async () => {
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
if (path === "/some/repo/agent-orchestrator.yaml") {
|
||||
return makeConfig(["app"]);
|
||||
}
|
||||
throw new Error(`unexpected loadConfig path: ${path}`);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor({ configPath: "/some/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/some/repo/agent-orchestrator.yaml");
|
||||
// No bare cwd-walk when the caller resolved a path for us.
|
||||
expect(mockLoadConfig).not.toHaveBeenCalledWith();
|
||||
expect(activeWorkers.has("app")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores the caller-provided configPath when the global config is healthy", async () => {
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
// Both paths would return a valid config — assert we only ever consult
|
||||
// the global path. The configPath is the fallback, not an override.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") return makeConfig(["app"]);
|
||||
if (path === "/repo/agent-orchestrator.yaml") {
|
||||
throw new Error("supervisor should not consult configPath when global is healthy");
|
||||
}
|
||||
throw new Error(`unexpected loadConfig path: ${path}`);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).not.toHaveBeenCalledWith("/repo/agent-orchestrator.yaml");
|
||||
expect(activeWorkers.has("app")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves workers across a global→fallback transition (multi-tick)", async () => {
|
||||
// Tick 1: global exists with {alpha, beta, gamma} — supervisor attaches
|
||||
// all three. Tick 2: global has been deleted; the local fallback config
|
||||
// (passed-in configPath) lists only {alpha}. Without the source-aware
|
||||
// detach skip, the second tick would kill beta and gamma even though
|
||||
// they're still running real sessions.
|
||||
sessionsByProject.set("alpha", [makeSession("alpha")]);
|
||||
sessionsByProject.set("beta", [makeSession("beta")]);
|
||||
sessionsByProject.set("gamma", [makeSession("gamma")]);
|
||||
|
||||
// Tick 1: global is the source.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") return makeConfig(["alpha", "beta", "gamma"]);
|
||||
throw new Error(`unexpected path on tick 1: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
expect(activeWorkers.has("alpha")).toBe(true);
|
||||
expect(activeWorkers.has("beta")).toBe(true);
|
||||
expect(activeWorkers.has("gamma")).toBe(true);
|
||||
|
||||
// Tick 2: global deleted, fallback has a narrower view.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
if (path === "/repo/agent-orchestrator.yaml") return makeConfig(["alpha"]);
|
||||
throw new Error(`unexpected path on tick 2: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
// All three workers must survive — fallback isn't authoritative for removal.
|
||||
expect(activeWorkers.has("alpha")).toBe(true);
|
||||
expect(activeWorkers.has("beta")).toBe(true);
|
||||
expect(activeWorkers.has("gamma")).toBe(true);
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("beta");
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("gamma");
|
||||
});
|
||||
|
||||
it("does detach when global is restored after a fallback period (symmetric flip)", async () => {
|
||||
// Documents intentional current behavior: when source flips back to
|
||||
// "global", the detach pass treats the global config as authoritative,
|
||||
// so projects not listed there ARE detached — including any that were
|
||||
// attached during a prior fallback window. The reviewer's guidance was
|
||||
// scoped to the fallback direction; protecting the symmetric flip would
|
||||
// require per-worker source tracking and is out of scope here.
|
||||
sessionsByProject.set("local-only", [makeSession("local-only")]);
|
||||
sessionsByProject.set("from-global", [makeSession("from-global")]);
|
||||
|
||||
// Tick 1: no global, fallback attaches local-only.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
if (path === "/repo/agent-orchestrator.yaml") return makeConfig(["local-only"]);
|
||||
throw new Error(`unexpected path on tick 1: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
expect(activeWorkers.has("local-only")).toBe(true);
|
||||
|
||||
// Tick 2: global appears (e.g. another `ao start <url>` wrote it),
|
||||
// listing only "from-global". Source = "global" → detach pass runs.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") return makeConfig(["from-global"]);
|
||||
throw new Error(`unexpected path on tick 2: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(activeWorkers.has("from-global")).toBe(true);
|
||||
expect(activeWorkers.has("local-only")).toBe(false);
|
||||
expect(mockRemoveProjectFromRunning).toHaveBeenCalledWith("local-only");
|
||||
});
|
||||
|
||||
it("does not detach unrelated active workers when operating from local fallback", async () => {
|
||||
// Simulates: daemon already supervising "other-project" (registered via
|
||||
// a prior reconcile against a global config that has since been deleted).
|
||||
// The current reconcile sees only "cwd-project" in the local fallback —
|
||||
// it must NOT treat "other-project" as removed.
|
||||
activeWorkers.add("other-project");
|
||||
sessionsByProject.set("cwd-project", [makeSession("cwd-project")]);
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
return makeConfig(["cwd-project"]);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(activeWorkers.has("other-project")).toBe(true);
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("other-project");
|
||||
// Attach pass still runs for the configured cwd project.
|
||||
expect(activeWorkers.has("cwd-project")).toBe(true);
|
||||
});
|
||||
|
||||
it("rethrows ENOENT from a nested file referenced by the global config", async () => {
|
||||
mockLoadConfig.mockImplementation(() => {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/some-referenced-file.yaml",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(reconcileProjectSupervisor()).rejects.toThrow("ENOENT");
|
||||
expect(mockLoadConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rethrows non-missing-config errors from the global config load", async () => {
|
||||
mockLoadConfig.mockImplementation(() => {
|
||||
throw new Error("invalid yaml");
|
||||
});
|
||||
|
||||
await expect(reconcileProjectSupervisor()).rejects.toThrow("invalid yaml");
|
||||
expect(mockLoadConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exits cleanly when neither global nor local config exists", async () => {
|
||||
const { ConfigNotFoundError } = await import("@aoagents/ao-core");
|
||||
mockLoadConfig
|
||||
.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new ConfigNotFoundError();
|
||||
});
|
||||
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_000 });
|
||||
|
||||
expect(handle).toEqual({
|
||||
stop: expect.any(Function),
|
||||
reconcileNow: expect.any(Function),
|
||||
});
|
||||
expect(mockEnsureLifecycleWorker).not.toHaveBeenCalled();
|
||||
handle.stop();
|
||||
});
|
||||
|
||||
it("forwards the supervisor interval to lifecycle workers it starts", async () => {
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
|
||||
const handle = await startProjectSupervisor(1_234);
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_234 });
|
||||
|
||||
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configPath: "/tmp/global-config.yaml" }),
|
||||
|
|
@ -280,7 +506,7 @@ describe("project-supervisor", () => {
|
|||
});
|
||||
|
||||
it("reconcileNow waits for a queued reconcile when one is already running", async () => {
|
||||
const handle = await startProjectSupervisor(1_000);
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_000 });
|
||||
let firstRelease: (() => void) | undefined;
|
||||
let secondRelease: (() => void) | undefined;
|
||||
let listCalls = 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Tests for resolve-project.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Covers MUST emits:
|
||||
* - cli.project_resolve_failed (clone failure inside fromUrl)
|
||||
* - cli.config_recovery_failed (registerFlatConfig returns null)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.fn(),
|
||||
// resolveCloneTarget points to a tmp dir; isRepoAlreadyCloned forces the
|
||||
// clone path so cloneRepo is invoked (and can throw).
|
||||
resolveCloneTarget: () => "/tmp/__ao_test_clone_target__",
|
||||
isRepoAlreadyCloned: () => false,
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/__ao_test_global_config__",
|
||||
projects: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/startup-preflight.js", () => ({
|
||||
ensureGit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
findFreePort: vi.fn().mockResolvedValue(3000),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
git: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
}));
|
||||
|
||||
import { resolveOrCreateProject } from "../../src/lib/resolve-project.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
describe("resolve-project — activity events", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AoCore.recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
it("emits cli.project_resolve_failed when cloneRepo throws (URL into running daemon)", async () => {
|
||||
const cloneRepo = vi.fn(
|
||||
async (_parsed: AoCore.ParsedRepoUrl, _target: string, _cwd: string) => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveOrCreateProject(
|
||||
"https://github.com/foo/bar",
|
||||
{
|
||||
addProjectToConfig: vi.fn(),
|
||||
autoCreateConfig: vi.fn(),
|
||||
resolveProject: vi.fn(),
|
||||
resolveProjectByRepo: vi.fn(),
|
||||
registerFlatConfig: vi.fn().mockResolvedValue(null),
|
||||
cloneRepo,
|
||||
},
|
||||
// targetGlobalRegistry: true → exercises fromUrlIntoGlobal
|
||||
{ targetGlobalRegistry: true },
|
||||
),
|
||||
).rejects.toThrow(/Failed to clone/);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.project_resolve_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
ownerRepo: "foo/bar",
|
||||
errorMessage: "network down",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.config_recovery_failed when registerFlatConfig returns null", async () => {
|
||||
// Trigger fromCwdOrId via undefined arg; if loadConfig() throws something
|
||||
// other than ConfigNotFoundError, the recovery path runs and asks
|
||||
// registerFlatConfig to fix it.
|
||||
//
|
||||
// Here we force the recovery path to fail by stubbing the deps so that:
|
||||
// 1. autoCreateConfig is not invoked (fromCwdOrId only calls it when
|
||||
// loadConfig throws ConfigNotFoundError — we trigger a different
|
||||
// error so the registerFlatConfig branch runs).
|
||||
// 2. registerFlatConfig returns null (recovery fails).
|
||||
//
|
||||
// We can't easily make the real loadConfig() throw a non-ConfigNotFoundError
|
||||
// synchronously, so instead we patch findConfigFile via the mocked module
|
||||
// surface. The simplest, robust approach: simulate the public call shape
|
||||
// and assert that whenever `registerFlatConfig` returns null, the event
|
||||
// fires at the call site. To do that we drive the function with a
|
||||
// controlled cwd that lacks a parseable config but has a config file
|
||||
// present — replicated by stubbing findConfigFile in @aoagents/ao-core.
|
||||
|
||||
// Reach into the same module mock by re-mocking findConfigFile + loadConfig.
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(AoCore.recordActivityEvent),
|
||||
// findConfigFile returns a path so the recovery branch runs.
|
||||
findConfigFile: () => "/tmp/__ao_test_flat_config__",
|
||||
// loadConfig throws a generic Error (not ConfigNotFoundError) so the
|
||||
// catch block falls into the registerFlatConfig recovery branch.
|
||||
loadConfig: () => {
|
||||
throw new Error("malformed config");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveOrCreateProject: resolveOrCreateProjectReloaded } =
|
||||
await import("../../src/lib/resolve-project.js");
|
||||
// Re-grab the mock so cleared calls inside the doMock factory don't get lost.
|
||||
const { recordActivityEvent: reloadedRecord } = await import("@aoagents/ao-core");
|
||||
vi.mocked(reloadedRecord).mockClear();
|
||||
|
||||
await expect(
|
||||
resolveOrCreateProjectReloaded(
|
||||
undefined,
|
||||
{
|
||||
addProjectToConfig: vi.fn(),
|
||||
autoCreateConfig: vi.fn(),
|
||||
resolveProject: vi.fn(),
|
||||
resolveProjectByRepo: vi.fn(),
|
||||
registerFlatConfig: vi.fn().mockResolvedValue(null),
|
||||
cloneRepo: vi.fn(),
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(/malformed config/);
|
||||
|
||||
const events = vi.mocked(reloadedRecord).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.config_recovery_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
configPath: "/tmp/__ao_test_flat_config__",
|
||||
errorMessage: "malformed config",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Tests for shutdown.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Each test mounts handlers via `installShutdownHandlers`, fires a signal,
|
||||
* and asserts the expected `cli.*` activity events are emitted. Real
|
||||
* `process.exit` is stubbed so the test process is not terminated by the
|
||||
* shutdown handler.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
const {
|
||||
mockListSessions,
|
||||
mockKillSession,
|
||||
mockGetSessionManager,
|
||||
mockUnregister,
|
||||
mockWriteLastStop,
|
||||
mockStopBunTmpJanitor,
|
||||
mockStopProjectSupervisor,
|
||||
mockStopAllLifecycleWorkers,
|
||||
mockLoadConfig,
|
||||
mockIsTerminalSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListSessions: vi.fn(),
|
||||
mockKillSession: vi.fn(),
|
||||
mockGetSessionManager: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
mockWriteLastStop: vi.fn(),
|
||||
mockStopBunTmpJanitor: vi.fn(),
|
||||
mockStopProjectSupervisor: vi.fn(),
|
||||
mockStopAllLifecycleWorkers: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockIsTerminalSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
isTerminalSession: (...args: unknown[]) => mockIsTerminalSession(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: (...args: unknown[]) => mockGetSessionManager(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/lifecycle-service.js", () => ({
|
||||
stopAllLifecycleWorkers: (...args: unknown[]) => mockStopAllLifecycleWorkers(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-supervisor.js", () => ({
|
||||
stopProjectSupervisor: (...args: unknown[]) => mockStopProjectSupervisor(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({
|
||||
stopBunTmpJanitor: (...args: unknown[]) => mockStopBunTmpJanitor(...args),
|
||||
}));
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
const flushAsync = async (): Promise<void> => {
|
||||
// The shutdown handler launches an async IIFE; allow it to settle.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
};
|
||||
|
||||
describe("shutdown handlers — activity events", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let originalListenersSigint: NodeJS.SignalsListener[];
|
||||
let originalListenersSigterm: NodeJS.SignalsListener[];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockListSessions.mockReset();
|
||||
mockKillSession.mockReset();
|
||||
mockGetSessionManager.mockReset();
|
||||
mockUnregister.mockReset();
|
||||
mockWriteLastStop.mockReset();
|
||||
mockStopBunTmpJanitor.mockReset();
|
||||
mockStopProjectSupervisor.mockReset();
|
||||
mockStopAllLifecycleWorkers.mockReset();
|
||||
mockLoadConfig.mockReset();
|
||||
mockIsTerminalSession.mockReset();
|
||||
|
||||
mockLoadConfig.mockReturnValue({ projects: {} });
|
||||
mockIsTerminalSession.mockReturnValue(false);
|
||||
mockGetSessionManager.mockResolvedValue({
|
||||
list: mockListSessions,
|
||||
kill: mockKillSession,
|
||||
});
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
mockKillSession.mockResolvedValue({ cleaned: true });
|
||||
mockUnregister.mockResolvedValue(undefined);
|
||||
mockWriteLastStop.mockResolvedValue(undefined);
|
||||
mockStopBunTmpJanitor.mockResolvedValue(undefined);
|
||||
|
||||
// Snapshot existing signal listeners so we can restore them after each
|
||||
// test and avoid leaking handlers across tests in the same process.
|
||||
originalListenersSigint = process.listeners("SIGINT") as NodeJS.SignalsListener[];
|
||||
originalListenersSigterm = process.listeners("SIGTERM") as NodeJS.SignalsListener[];
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
// Throw a sentinel to short-circuit the async IIFE without leaving
|
||||
// the test process in an unknown state.
|
||||
return undefined as never;
|
||||
}) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
|
||||
// Restore signal listeners
|
||||
process.removeAllListeners("SIGINT");
|
||||
process.removeAllListeners("SIGTERM");
|
||||
for (const l of originalListenersSigint) process.on("SIGINT", l);
|
||||
for (const l of originalListenersSigterm) process.on("SIGTERM", l);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_signal when SIGINT is received", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_signal",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
data: expect.objectContaining({ signal: "SIGINT", exitCode: 130 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_completed after clean shutdown", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_completed",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_failed when shutdown body throws before completion", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
mockGetSessionManager.mockRejectedValue(new Error("getSessionManager boom"));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ errorMessage: "getSessionManager boom" }),
|
||||
}),
|
||||
);
|
||||
// Failure path should NOT emit shutdown_completed
|
||||
const completedEvents = events.filter((e) => e.kind === "cli.shutdown_completed");
|
||||
expect(completedEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still unregisters running state when writing last-stop state fails", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
mockListSessions.mockResolvedValue([
|
||||
{
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockWriteLastStop.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
expect(mockWriteLastStop).toHaveBeenCalled();
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.last_stop_write_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
targetSessionCount: 1,
|
||||
otherProjectCount: 0,
|
||||
totalKilled: 1,
|
||||
errorMessage: "disk full",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_completed",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
}),
|
||||
);
|
||||
expect(events.filter((e) => e.kind === "cli.shutdown_failed")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_force_exit when the 10s timer fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
// Hang the async cleanup so the force-exit timer wins.
|
||||
mockGetSessionManager.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
// Advance past the 10s timeout
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_force_exit",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({ timeoutMs: 10_000, exitCode: 130 }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -84,6 +84,7 @@ import {
|
|||
maybeShowUpdateNotice,
|
||||
scheduleBackgroundRefresh,
|
||||
isVersionOutdated,
|
||||
isOutdatedForChannel,
|
||||
resolveUpdateChannel,
|
||||
resolveInstallMethodOverride,
|
||||
isManualOnlyInstall,
|
||||
|
|
@ -115,6 +116,42 @@ describe("update-check", () => {
|
|||
mockGlobalConfig.value = null;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isOutdatedForChannel
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("isOutdatedForChannel", () => {
|
||||
it("treats any nightly dist-tag identity change as an update in both lexical directions", () => {
|
||||
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe(true);
|
||||
expect(isOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe(true);
|
||||
expect(
|
||||
isOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not update nightly when the exact dist-tag version is already installed", () => {
|
||||
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses semver for stable-to-nightly channel switches", () => {
|
||||
expect(isOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false);
|
||||
expect(isOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true);
|
||||
expect(isOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => {
|
||||
// `fetchLatestVersion("nightly")` can fall back to `latest` if the
|
||||
// nightly dist-tag is absent; prerelease-to-stable is a normal
|
||||
// semver upgrade.
|
||||
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps stable comparisons numeric instead of lexical", () => {
|
||||
expect(isOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true);
|
||||
expect(isOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// isVersionOutdated
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -1186,24 +1223,14 @@ describe("update-check", () => {
|
|||
|
||||
describe("getUpdateCommand — channel-aware (Section B)", () => {
|
||||
it("uses @nightly tag for nightly channel", () => {
|
||||
expect(getUpdateCommand("npm-global", "nightly")).toBe(
|
||||
"npm install -g @aoagents/ao@nightly",
|
||||
);
|
||||
expect(getUpdateCommand("pnpm-global", "nightly")).toBe(
|
||||
"pnpm add -g @aoagents/ao@nightly",
|
||||
);
|
||||
expect(getUpdateCommand("bun-global", "nightly")).toBe(
|
||||
"bun add -g @aoagents/ao@nightly",
|
||||
);
|
||||
expect(getUpdateCommand("npm-global", "nightly")).toBe("npm install -g @aoagents/ao@nightly");
|
||||
expect(getUpdateCommand("pnpm-global", "nightly")).toBe("pnpm add -g @aoagents/ao@nightly");
|
||||
expect(getUpdateCommand("bun-global", "nightly")).toBe("bun add -g @aoagents/ao@nightly");
|
||||
});
|
||||
|
||||
it("uses @latest tag for stable + manual channels", () => {
|
||||
expect(getUpdateCommand("npm-global", "stable")).toBe(
|
||||
"npm install -g @aoagents/ao@latest",
|
||||
);
|
||||
expect(getUpdateCommand("npm-global", "manual")).toBe(
|
||||
"npm install -g @aoagents/ao@latest",
|
||||
);
|
||||
expect(getUpdateCommand("npm-global", "stable")).toBe("npm install -g @aoagents/ao@latest");
|
||||
expect(getUpdateCommand("npm-global", "manual")).toBe("npm install -g @aoagents/ao@latest");
|
||||
});
|
||||
|
||||
it("returns the brew upgrade notice for homebrew installs", () => {
|
||||
|
|
|
|||
|
|
@ -10,4 +10,13 @@ describe("createProgram", () => {
|
|||
it("registers the project command", () => {
|
||||
expect(createProgram().commands.some((command) => command.name() === "project")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the notify command", () => {
|
||||
const notify = createProgram().commands.find((command) => command.name() === "notify");
|
||||
expect(notify?.commands.some((command) => command.name() === "test")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the review command", () => {
|
||||
expect(createProgram().commands.some((command) => command.name() === "review")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
|
|
@ -34,7 +35,14 @@ function createHealthyRepo(tempRoot: string): string {
|
|||
mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true });
|
||||
mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true });
|
||||
mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true });
|
||||
writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n");
|
||||
writeFileSync(
|
||||
join(fakeRepo, "packages", "core", "package.json"),
|
||||
JSON.stringify({ type: "module", main: "dist/index.js" }, null, 2),
|
||||
);
|
||||
writeFileSync(
|
||||
join(fakeRepo, "packages", "core", "dist", "index.js"),
|
||||
'export function getNodePtyPrebuildsSubdir() { return process.platform + "-" + process.arch; }\n',
|
||||
);
|
||||
writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n");
|
||||
writeFileSync(
|
||||
join(fakeRepo, "packages", "ao", "bin", "ao.js"),
|
||||
|
|
@ -61,7 +69,7 @@ function createHealthyPath(binDir: string): void {
|
|||
createFakeBinary(
|
||||
binDir,
|
||||
"node",
|
||||
'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0',
|
||||
`if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
|
|
@ -252,6 +260,67 @@ exit 0`,
|
|||
expect(npmCommands).toContain("link --force");
|
||||
});
|
||||
|
||||
it("warns about and repairs a non-executable node-pty spawn-helper", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-node-pty-helper-"));
|
||||
const fakeRepo = createHealthyRepo(tempRoot);
|
||||
const binDir = join(tempRoot, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
createHealthyPath(binDir);
|
||||
|
||||
const helperPath = join(
|
||||
fakeRepo,
|
||||
"node_modules",
|
||||
"node-pty",
|
||||
"prebuilds",
|
||||
`${process.platform}-${process.arch}`,
|
||||
"spawn-helper",
|
||||
);
|
||||
mkdirSync(dirname(helperPath), { recursive: true });
|
||||
writeFileSync(join(fakeRepo, "node_modules", "node-pty", "package.json"), "{}\n");
|
||||
writeFileSync(helperPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(helperPath, 0o644);
|
||||
|
||||
const configPath = join(tempRoot, "agent-orchestrator.yaml");
|
||||
const dataDir = join(tempRoot, "data");
|
||||
const worktreeDir = join(tempRoot, "worktrees");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"),
|
||||
);
|
||||
|
||||
const warnResult = spawnSync("bash", [scriptPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:/bin:/usr/bin`,
|
||||
AO_REPO_ROOT: fakeRepo,
|
||||
AO_CONFIG_PATH: configPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const fixResult = spawnSync("bash", [scriptPath, "--fix"], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:/bin:/usr/bin`,
|
||||
AO_REPO_ROOT: fakeRepo,
|
||||
AO_CONFIG_PATH: configPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const statMode = (statSync(helperPath).mode & 0o111) !== 0;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
|
||||
expect(warnResult.status).toBe(0);
|
||||
expect(warnResult.stdout).toContain("WARN node-pty spawn-helper is not executable");
|
||||
expect(warnResult.stdout).toContain("posix_spawnp failed");
|
||||
expect(fixResult.status).toBe(0);
|
||||
expect(fixResult.stdout).toContain("FIXED chmod +x applied to node-pty spawn-helper");
|
||||
expect(statMode).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a healthy packaged install without source-checkout failures", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-package-"));
|
||||
const fakeInstall = createHealthyPackageInstall(tempRoot);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-cli",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "CLI for agent-orchestrator — the `ao` command",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
@ -34,13 +34,16 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aoagents/ao-core": "workspace:*",
|
||||
"@aoagents/ao-notifier-macos": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-aider": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-claude-code": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-codex": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-cursor": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-grok": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-kimicode": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-opencode": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-composio": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-dashboard": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-desktop": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-discord": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-openclaw": "workspace:*",
|
||||
|
|
@ -57,14 +60,16 @@
|
|||
"@aoagents/ao-plugin-workspace-worktree": "workspace:*",
|
||||
"@aoagents/ao-web": "workspace:*",
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"@composio/core": "^0.9.0",
|
||||
"chalk": "^5.4.0",
|
||||
"commander": "^13.0.0",
|
||||
"ora": "^8.1.0",
|
||||
"yaml": "^2.7.0"
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ while [ $# -gt 0 ]; do
|
|||
cat <<'EOF'
|
||||
Usage: ao doctor [--fix]
|
||||
|
||||
Checks install, PATH, binaries, service health, stale temp files, and runtime sanity.
|
||||
Checks install, PATH, binaries, service health, web terminal support, stale temp files, and runtime sanity.
|
||||
|
||||
Options:
|
||||
--fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files
|
||||
--fix Apply safe fixes for missing launcher links, missing support dirs, node-pty spawn-helper permissions, and stale temp files
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
|
|
@ -398,6 +398,136 @@ check_stale_temp_files() {
|
|||
warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix"
|
||||
}
|
||||
|
||||
file_mode_octal() {
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
Darwin|FreeBSD|OpenBSD|NetBSD)
|
||||
stat -f '%Lp' "$1" 2>/dev/null || printf 'unknown'
|
||||
;;
|
||||
*)
|
||||
stat -c '%a' "$1" 2>/dev/null || printf 'unknown'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_node_pty_spawn_helper() {
|
||||
node - "$REPO_ROOT" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const { createRequire } = require("node:module");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const repoRoot = process.argv[2];
|
||||
|
||||
function resolvePackageJson(fromDir) {
|
||||
try {
|
||||
return createRequire(path.join(fromDir, "ao-doctor.js")).resolve("node-pty/package.json");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findPackageUp(startDir, ...segments) {
|
||||
let dir = path.resolve(startDir);
|
||||
while (true) {
|
||||
const candidate = path.resolve(dir, "node_modules", ...segments);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveNodeModulesPackage(fromDir, ...segments) {
|
||||
const packageDir = path.resolve(fromDir, "node_modules", ...segments);
|
||||
return fs.existsSync(path.resolve(packageDir, "package.json")) ? packageDir : null;
|
||||
}
|
||||
|
||||
function resolveCoreEntrypoint() {
|
||||
const sourceCoreDir = path.resolve(repoRoot, "packages", "core");
|
||||
const coreDir =
|
||||
(fs.existsSync(path.join(sourceCoreDir, "package.json")) ? sourceCoreDir : null) ??
|
||||
findPackageUp(repoRoot, "@aoagents", "ao-core") ??
|
||||
resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-core");
|
||||
if (!coreDir) return null;
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8"));
|
||||
const entry = pkg.exports?.["."]?.import ?? pkg.module ?? pkg.main ?? "dist/index.js";
|
||||
return path.resolve(coreDir, entry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getNodePtyPrebuildsSubdir() {
|
||||
const coreEntrypoint = resolveCoreEntrypoint();
|
||||
if (!coreEntrypoint || !fs.existsSync(coreEntrypoint)) return null;
|
||||
|
||||
const core = await import(pathToFileURL(coreEntrypoint).href);
|
||||
return typeof core.getNodePtyPrebuildsSubdir === "function"
|
||||
? core.getNodePtyPrebuildsSubdir()
|
||||
: null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const packageJsonPath =
|
||||
resolvePackageJson(repoRoot) ??
|
||||
(() => {
|
||||
const directNodePtyDir = findPackageUp(repoRoot, "node-pty");
|
||||
if (directNodePtyDir) return path.join(directNodePtyDir, "package.json");
|
||||
|
||||
const sourceWebDir = path.resolve(repoRoot, "packages", "web");
|
||||
const webDir =
|
||||
(fs.existsSync(path.join(sourceWebDir, "package.json")) ? sourceWebDir : null) ??
|
||||
findPackageUp(repoRoot, "@aoagents", "ao-web") ??
|
||||
resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-web");
|
||||
if (!webDir) return null;
|
||||
|
||||
const webNodePtyDir =
|
||||
resolveNodeModulesPackage(webDir, "node-pty") ?? findPackageUp(webDir, "node-pty");
|
||||
return webNodePtyDir ? path.join(webNodePtyDir, "package.json") : null;
|
||||
})();
|
||||
|
||||
const prebuildsSubdir = await getNodePtyPrebuildsSubdir();
|
||||
if (!packageJsonPath || !prebuildsSubdir) process.exit(0);
|
||||
|
||||
console.log(path.join(path.dirname(packageJsonPath), "prebuilds", prebuildsSubdir, "spawn-helper"));
|
||||
})().catch(() => process.exit(0));
|
||||
NODE
|
||||
}
|
||||
|
||||
check_node_pty_spawn_helper() {
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
warn "node-pty spawn-helper check skipped because node is unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
local helper_path mode
|
||||
helper_path="$(resolve_node_pty_spawn_helper 2>/dev/null || true)"
|
||||
if [ -z "$helper_path" ] || [ ! -f "$helper_path" ]; then
|
||||
pass "node-pty spawn-helper check skipped because no helper was found for this platform"
|
||||
return
|
||||
fi
|
||||
|
||||
mode="$(file_mode_octal "$helper_path")"
|
||||
if [ -x "$helper_path" ]; then
|
||||
pass "node-pty spawn-helper is executable at $helper_path (mode 0o$mode)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$FIX_MODE" = true ]; then
|
||||
if chmod 755 "$helper_path"; then
|
||||
fixed "chmod +x applied to node-pty spawn-helper at $helper_path (was 0o$mode)"
|
||||
return
|
||||
fi
|
||||
warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode), and chmod failed. Web dashboard terminals can fail with posix_spawnp failed. Fix: chmod +x $helper_path"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode). Web dashboard terminals can fail with posix_spawnp failed. Fix: run ao doctor --fix or chmod +x $helper_path. See ao#1770."
|
||||
}
|
||||
|
||||
printf 'Agent Orchestrator Doctor\n\n'
|
||||
|
||||
check_node
|
||||
|
|
@ -409,6 +539,7 @@ check_gh
|
|||
check_config_dirs
|
||||
check_stale_temp_files
|
||||
check_install_layout
|
||||
check_node_pty_spawn_helper
|
||||
check_runtime_sanity
|
||||
|
||||
printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "../lib/dashboard-rebuild.js";
|
||||
import { preflight } from "../lib/preflight.js";
|
||||
import { DEFAULT_PORT } from "../lib/constants.js";
|
||||
import { dashboardUrl } from "../lib/dashboard-url.js";
|
||||
|
||||
export function registerDashboard(program: Command): void {
|
||||
program
|
||||
|
|
@ -42,7 +43,7 @@ export function registerDashboard(program: Command): void {
|
|||
|
||||
const webDir = localWebDir;
|
||||
|
||||
console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`));
|
||||
console.log(chalk.bold(`Starting dashboard on ${dashboardUrl(port)}\n`));
|
||||
|
||||
const env = await buildDashboardEnv(
|
||||
port,
|
||||
|
|
@ -90,7 +91,7 @@ export function registerDashboard(program: Command): void {
|
|||
|
||||
if (opts.open !== false) {
|
||||
openAbort = new AbortController();
|
||||
void waitForPortAndOpen(port, `http://localhost:${port}`, openAbort.signal);
|
||||
void waitForPortAndOpen(port, dashboardUrl(port), openAbort.signal);
|
||||
}
|
||||
|
||||
child.on("exit", (code) => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import {
|
|||
getObservabilityBaseDir,
|
||||
loadConfig,
|
||||
resolveNotifierTarget,
|
||||
type Notifier,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type PluginSlot,
|
||||
} from "@aoagents/ao-core";
|
||||
import { runNotifyTest } from "../lib/notify-test.js";
|
||||
import { runRepoScript } from "../lib/script-runner.js";
|
||||
import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js";
|
||||
import { importPluginModuleFromSource } from "../lib/plugin-store.js";
|
||||
|
|
@ -338,54 +338,34 @@ async function sendTestNotifications(
|
|||
registry: PluginRegistry,
|
||||
fail: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
const activeNotifierNames = config.defaults?.notifiers ?? [];
|
||||
const targets = new Map<string, ReturnType<typeof resolveNotifierTarget>>();
|
||||
const result = await runNotifyTest(config, registry, {
|
||||
templateName: "basic",
|
||||
all: true,
|
||||
message: "Test notification from ao doctor --test-notify",
|
||||
sessionId: "doctor-test",
|
||||
projectId: "doctor",
|
||||
data: { source: "ao-doctor" },
|
||||
});
|
||||
|
||||
for (const name of Object.keys(config.notifiers ?? {})) {
|
||||
targets.set(name, resolveNotifierTarget(config, name));
|
||||
}
|
||||
|
||||
for (const name of activeNotifierNames) {
|
||||
const target = resolveNotifierTarget(config, name);
|
||||
if (!targets.has(target.reference)) {
|
||||
targets.set(target.reference, target);
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.size === 0) {
|
||||
if (result.targets.length === 0) {
|
||||
warn("No notifiers to test. Fix: configure notifiers in your agent-orchestrator.yaml");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nSending test notification to ${targets.size} notifier(s)...\n`);
|
||||
console.log(`\nSending test notification to ${result.targets.length} notifier(s)...\n`);
|
||||
|
||||
for (const target of targets.values()) {
|
||||
const notifier =
|
||||
registry.get<Notifier>("notifier", target.reference) ??
|
||||
registry.get<Notifier>("notifier", target.pluginName);
|
||||
if (!notifier) {
|
||||
warn(`${target.reference}: plugin "${target.pluginName}" not loaded (may not be installed)`);
|
||||
continue;
|
||||
for (const delivery of result.deliveries) {
|
||||
if (delivery.status === "sent") {
|
||||
pass(`${delivery.reference}: test notification sent`);
|
||||
} else if (delivery.status === "unresolved") {
|
||||
warn(`${delivery.reference}: plugin "${delivery.pluginName}" not loaded (may not be installed)`);
|
||||
} else if (delivery.error) {
|
||||
fail(delivery.error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const testEvent = {
|
||||
id: `doctor-test-${Date.now()}`,
|
||||
type: "summary.all_complete" as const,
|
||||
priority: "info" as const,
|
||||
sessionId: "doctor-test",
|
||||
projectId: "doctor",
|
||||
timestamp: new Date(),
|
||||
message: "Test notification from ao doctor --test-notify",
|
||||
data: { source: "ao-doctor" },
|
||||
};
|
||||
|
||||
await notifier.notify(testEvent);
|
||||
pass(`${target.reference}: test notification sent`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
fail(`${target.reference}: ${message}`);
|
||||
}
|
||||
for (const warning of result.warnings) {
|
||||
warn(warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type ActivityEvent,
|
||||
type ActivityEventLevel,
|
||||
type ActivityEventKind,
|
||||
type ActivityEventSource,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
interface JsonEnvelope {
|
||||
|
|
@ -80,7 +81,12 @@ export function registerEvents(program: Command): void {
|
|||
.description("List recent activity events")
|
||||
.option("-p, --project <id>", "Filter by project ID")
|
||||
.option("-s, --session <id>", "Filter by session ID")
|
||||
.option("-t, --type <kind>", "Filter by event kind (e.g. session.spawned, lifecycle.transition)")
|
||||
.option(
|
||||
"-t, --type <kind>",
|
||||
"Filter by event kind (e.g. session.spawned, lifecycle.transition)",
|
||||
)
|
||||
.option("--kind <kind>", "Alias for --type")
|
||||
.option("--source <source>", "Filter by event source (e.g. lifecycle, recovery, api)")
|
||||
.option("--log-level <level>", "Filter by log level (debug, info, warn, error)")
|
||||
.option("--since <duration>", "Show events from last N minutes/hours/days (e.g. 30m, 2h, 1d)")
|
||||
.option("-n, --limit <n>", "Max results", "50")
|
||||
|
|
@ -91,15 +97,21 @@ export function registerEvents(program: Command): void {
|
|||
if (sinceRaw) {
|
||||
since = parseSinceDuration(sinceRaw);
|
||||
if (!since) {
|
||||
console.error(chalk.yellow(`Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`));
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const limit = parseInt(opts["limit"] ?? "50", 10);
|
||||
const kind = opts["type"] ?? opts["kind"];
|
||||
|
||||
const results = queryActivityEvents({
|
||||
projectId: opts["project"],
|
||||
sessionId: opts["session"],
|
||||
kind: opts["type"] as ActivityEventKind,
|
||||
kind: kind as ActivityEventKind,
|
||||
source: opts["source"] as ActivityEventSource,
|
||||
level: opts["logLevel"] as ActivityEventLevel,
|
||||
since,
|
||||
limit,
|
||||
|
|
@ -111,7 +123,8 @@ export function registerEvents(program: Command): void {
|
|||
query: {
|
||||
projectId: opts["project"] ?? null,
|
||||
sessionId: opts["session"] ?? null,
|
||||
kind: opts["type"] ?? null,
|
||||
kind: kind ?? null,
|
||||
source: opts["source"] ?? null,
|
||||
level: opts["logLevel"] ?? null,
|
||||
since: sinceRaw ?? null,
|
||||
limit,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { migrateStorage, rollbackStorage } from "@aoagents/ao-core";
|
||||
import { migrateStorage, recordActivityEvent, rollbackStorage } from "@aoagents/ao-core";
|
||||
|
||||
export function registerMigrateStorage(program: Command): void {
|
||||
program
|
||||
|
|
@ -13,12 +13,31 @@ export function registerMigrateStorage(program: Command): void {
|
|||
.option("--rollback", "Reverse a previous migration (restores .migrated directories)")
|
||||
.action(
|
||||
async (opts: { dryRun?: boolean; force?: boolean; rollback?: boolean }) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_invoked",
|
||||
level: "info",
|
||||
summary: `storage ${opts.rollback ? "rollback" : "migration"} invoked`,
|
||||
data: {
|
||||
rollback: opts.rollback === true,
|
||||
dryRun: opts.dryRun === true,
|
||||
force: opts.force === true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (opts.rollback) {
|
||||
await rollbackStorage({
|
||||
dryRun: opts.dryRun,
|
||||
log: (msg) => console.log(msg),
|
||||
});
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_completed",
|
||||
level: "info",
|
||||
summary: `storage rollback completed`,
|
||||
data: { rollback: true, dryRun: opts.dryRun === true },
|
||||
});
|
||||
} else {
|
||||
const result = await migrateStorage({
|
||||
dryRun: opts.dryRun,
|
||||
|
|
@ -31,8 +50,30 @@ export function registerMigrateStorage(program: Command): void {
|
|||
} else {
|
||||
console.log(chalk.green("\nMigration complete."));
|
||||
}
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_completed",
|
||||
level: "info",
|
||||
summary: `storage migration completed (${result.projects} project(s))`,
|
||||
data: {
|
||||
rollback: false,
|
||||
dryRun: opts.dryRun === true,
|
||||
force: opts.force === true,
|
||||
projects: result.projects,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_failed",
|
||||
level: "error",
|
||||
summary: `storage migration failed`,
|
||||
data: {
|
||||
rollback: opts.rollback === true,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import type { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
createPluginRegistry,
|
||||
findConfigFile,
|
||||
loadConfig,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
import { importPluginModuleFromSource } from "../lib/plugin-store.js";
|
||||
import {
|
||||
addSinkNotifierConfig,
|
||||
parseNotifyDataJson,
|
||||
parseNotifyRefs,
|
||||
parseSinkPort,
|
||||
runNotifyTest,
|
||||
startNotifySink,
|
||||
type NotifySinkServer,
|
||||
type NotifyTestRequest,
|
||||
type NotifyTestResult,
|
||||
} from "../lib/notify-test.js";
|
||||
|
||||
interface NotifyTestCommandOptions {
|
||||
template?: string;
|
||||
to?: string;
|
||||
all?: boolean;
|
||||
route?: string;
|
||||
actions?: boolean;
|
||||
message?: string;
|
||||
session?: string;
|
||||
project?: string;
|
||||
priority?: string;
|
||||
type?: string;
|
||||
data?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
sink?: true | string;
|
||||
}
|
||||
|
||||
async function loadNotifierRegistry(config: OrchestratorConfig): Promise<PluginRegistry> {
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config, importPluginModuleFromSource);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function printJson(result: NotifyTestResult, sinkRequests?: unknown[]): void {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...result,
|
||||
sinkRequests,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function printHumanResult(result: NotifyTestResult, sinkRequests?: unknown[]): void {
|
||||
console.log(
|
||||
`${result.dryRun ? "Dry run" : "Sent"} ${result.templateName} notification (${result.event.type}, ${result.event.priority})`,
|
||||
);
|
||||
console.log(`Event id: ${result.event.id}`);
|
||||
console.log(`Session: ${result.event.projectId}/${result.event.sessionId}`);
|
||||
|
||||
if (result.targets.length > 0) {
|
||||
console.log("");
|
||||
console.log("Targets:");
|
||||
for (const target of result.targets) {
|
||||
console.log(` ${target.reference} -> ${target.pluginName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.deliveries.length > 0) {
|
||||
console.log("");
|
||||
console.log("Delivery:");
|
||||
for (const delivery of result.deliveries) {
|
||||
if (delivery.status === "sent") {
|
||||
console.log(` ${chalk.green("PASS")} ${delivery.reference}: ${delivery.method}`);
|
||||
} else if (delivery.status === "dry_run") {
|
||||
console.log(` ${chalk.cyan("DRY")} ${delivery.reference}: ${delivery.method}`);
|
||||
} else {
|
||||
console.log(` ${chalk.red("FAIL")} ${delivery.reference}: ${delivery.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const warning of result.warnings) {
|
||||
console.log(`${chalk.yellow("WARN")} ${warning}`);
|
||||
}
|
||||
|
||||
for (const error of result.errors) {
|
||||
console.error(`${chalk.red("FAIL")} ${error}`);
|
||||
}
|
||||
|
||||
if (sinkRequests && sinkRequests.length > 0) {
|
||||
console.log("");
|
||||
console.log("Sink received:");
|
||||
console.log(JSON.stringify(sinkRequests[0], null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function commandRequest(opts: NotifyTestCommandOptions, forceSinkTarget: boolean): NotifyTestRequest {
|
||||
const request: NotifyTestRequest = {
|
||||
templateName: opts.template,
|
||||
to: forceSinkTarget ? ["sink"] : parseNotifyRefs(opts.to),
|
||||
all: opts.all,
|
||||
route: opts.route,
|
||||
actions: opts.actions,
|
||||
message: opts.message,
|
||||
sessionId: opts.session,
|
||||
projectId: opts.project,
|
||||
priority: opts.priority,
|
||||
type: opts.type,
|
||||
data: parseNotifyDataJson(opts.data),
|
||||
dryRun: opts.dryRun,
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export function registerNotify(program: Command): void {
|
||||
const notify = program.command("notify").description("Work with configured notification targets");
|
||||
|
||||
notify
|
||||
.command("test")
|
||||
.description("Send a manual demo notification without spawning sessions")
|
||||
.option("--template <name>", "Demo template to send", "basic")
|
||||
.option("--to <refs>", "Comma-separated notifier refs to target")
|
||||
.option("--all", "Send to all configured, default, and routed notifier refs")
|
||||
.option("--route <urgent|action|warning|info>", "Send through a priority route")
|
||||
.option("--actions", "Send demo actions when supported")
|
||||
.option("--message <text>", "Override the notification message")
|
||||
.option("--session <id>", "Override the demo session id")
|
||||
.option("--project <id>", "Override the demo project id")
|
||||
.option("--priority <level>", "Override the event priority")
|
||||
.option("--type <eventType>", "Override the event type")
|
||||
.option("--data <json>", "Merge JSON object into the event data")
|
||||
.option("--dry-run", "Resolve and print the notification without sending it")
|
||||
.option("--json", "Print structured JSON output")
|
||||
.option("--sink [port]", "Add an in-memory local webhook target named sink")
|
||||
.action(async (opts: NotifyTestCommandOptions) => {
|
||||
let sink: NotifySinkServer | undefined;
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const sinkPort = parseSinkPort(opts.sink);
|
||||
const forceSinkTarget = sinkPort !== undefined && !opts.to && !opts.all && !opts.route;
|
||||
const request = commandRequest(opts, forceSinkTarget);
|
||||
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No config file found. Cannot test notifiers without agent-orchestrator.yaml");
|
||||
}
|
||||
|
||||
let config: OrchestratorConfig = loadConfig(configPath);
|
||||
if (sinkPort !== undefined) {
|
||||
const sinkUrl = opts.dryRun ? `http://127.0.0.1:${sinkPort}` : undefined;
|
||||
if (!opts.dryRun) {
|
||||
sink = await startNotifySink(sinkPort);
|
||||
}
|
||||
config = addSinkNotifierConfig(config, sink?.url ?? sinkUrl ?? "http://127.0.0.1:0");
|
||||
}
|
||||
|
||||
const registry = await loadNotifierRegistry(config);
|
||||
const result = await runNotifyTest(config, registry, request);
|
||||
const sinkRequest = sink ? await sink.waitForRequest(1000) : null;
|
||||
const sinkRequests = sinkRequest ? [sinkRequest] : sink?.requests;
|
||||
|
||||
if (opts.json) {
|
||||
printJson(result, sinkRequests);
|
||||
} else {
|
||||
printHumanResult(result, sinkRequests);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, errors: [message] }, null, 2));
|
||||
} else {
|
||||
console.error(`${chalk.red("FAIL")} ${message}`);
|
||||
}
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
if (sink) {
|
||||
await sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -509,38 +509,51 @@ export function registerPlugin(program: Command): void {
|
|||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)",
|
||||
"Remote/manual OpenClaw token fallback (passed to setup when installing notifier-openclaw)",
|
||||
)
|
||||
.action(async (reference: string, opts: { url?: string; token?: string }) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath);
|
||||
const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier);
|
||||
const previousPlugins = config.plugins ?? [];
|
||||
const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor);
|
||||
writePluginsConfig(configPath, nextPlugins);
|
||||
|
||||
console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath)));
|
||||
|
||||
if (setupAction === "openclaw-setup") {
|
||||
// Always run setup — interactive in TTY, auto-detect in non-TTY.
|
||||
// Non-interactive setup will auto-detect OpenClaw on localhost if
|
||||
// no --url is given and the gateway is reachable.
|
||||
try {
|
||||
await runSetupAction({ url: opts.url, token: opts.token });
|
||||
} catch (err) {
|
||||
// Rollback: restore previous plugin list so a failed setup
|
||||
// doesn't leave a half-configured notifier enabled in config.
|
||||
writePluginsConfig(configPath, previousPlugins);
|
||||
console.log(chalk.dim("Rolled back plugin config after setup failure."));
|
||||
throw err;
|
||||
.option(
|
||||
"--openclaw-config-path <path>",
|
||||
"OpenClaw config path that contains hooks.token (passed to setup when installing notifier-openclaw)",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
reference: string,
|
||||
opts: { url?: string; token?: string; openclawConfigPath?: string },
|
||||
) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath);
|
||||
const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier);
|
||||
const previousPlugins = config.plugins ?? [];
|
||||
const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor);
|
||||
writePluginsConfig(configPath, nextPlugins);
|
||||
|
||||
console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath)));
|
||||
|
||||
if (setupAction === "openclaw-setup") {
|
||||
// Always run setup — interactive in TTY, auto-detect in non-TTY.
|
||||
// Non-interactive setup will auto-detect OpenClaw on localhost if
|
||||
// no --url is given and the gateway is reachable.
|
||||
try {
|
||||
await runSetupAction({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
openclawConfigPath: opts.openclawConfigPath,
|
||||
});
|
||||
} catch (err) {
|
||||
// Rollback: restore previous plugin list so a failed setup
|
||||
// doesn't leave a half-configured notifier enabled in config.
|
||||
writePluginsConfig(configPath, previousPlugins);
|
||||
console.log(chalk.dim("Rolled back plugin config after setup failure."));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
plugin
|
||||
.command("update")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getPortfolio,
|
||||
getPortfolioSessionCounts,
|
||||
isPortfolioEnabled,
|
||||
recordActivityEvent,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
loadPreferences,
|
||||
|
|
@ -94,18 +95,48 @@ export function registerProjectCommand(program: Command): void {
|
|||
const existingConfigPath = candidatePaths.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!existingConfigPath) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "warn",
|
||||
summary: `ao project add: no agent-orchestrator config found`,
|
||||
data: { resolvedPath, reason: "no_config_found" },
|
||||
});
|
||||
console.error(chalk.red(`No agent-orchestrator.yaml found at ${resolvedPath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
loadConfig(existingConfigPath);
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "warn",
|
||||
summary: `ao project add: found old-format config requiring migration`,
|
||||
data: {
|
||||
resolvedPath,
|
||||
configPath: existingConfigPath,
|
||||
reason: "old_format",
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Found old-format config at ${existingConfigPath}. Run \`ao start\` in that project to migrate it before using \`ao project add\`.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "error",
|
||||
summary: `ao project add: config load failed`,
|
||||
data: {
|
||||
resolvedPath,
|
||||
configPath: existingConfigPath,
|
||||
reason: "load_error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Found agent-orchestrator config at ${existingConfigPath}, but it could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
createShellCodeReviewRunner,
|
||||
createCodeReviewStore,
|
||||
executeCodeReviewRun,
|
||||
loadConfig,
|
||||
sendCodeReviewFindingsToAgent,
|
||||
SessionNotFoundError,
|
||||
triggerCodeReviewForSession,
|
||||
type CodeReviewRunStatus,
|
||||
type CodeReviewRunSummary,
|
||||
} from "@aoagents/ao-core";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
||||
const RUN_STATUSES: ReadonlySet<CodeReviewRunStatus> = new Set([
|
||||
"queued",
|
||||
"preparing",
|
||||
"running",
|
||||
"needs_triage",
|
||||
"sent_to_agent",
|
||||
"waiting_update",
|
||||
"clean",
|
||||
"outdated",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
function parseRunStatus(value: string | undefined): CodeReviewRunStatus | undefined {
|
||||
if (!value) return undefined;
|
||||
if (RUN_STATUSES.has(value as CodeReviewRunStatus)) {
|
||||
return value as CodeReviewRunStatus;
|
||||
}
|
||||
throw new Error(`Unknown review status: ${value}`);
|
||||
}
|
||||
|
||||
function printRun(run: CodeReviewRunSummary): void {
|
||||
const findings =
|
||||
run.openFindingCount === 1 ? "1 open finding" : `${run.openFindingCount} open findings`;
|
||||
const parts = [
|
||||
chalk.green(run.reviewerSessionId),
|
||||
chalk.dim(run.id),
|
||||
run.status,
|
||||
chalk.cyan(run.linkedSessionId),
|
||||
findings,
|
||||
];
|
||||
|
||||
if (run.prNumber) {
|
||||
parts.push(chalk.blue(`PR #${run.prNumber}`));
|
||||
}
|
||||
|
||||
console.log(parts.join(" "));
|
||||
}
|
||||
|
||||
function printSendResult(run: CodeReviewRunSummary, sentFindingCount: number): void {
|
||||
const findings = sentFindingCount === 1 ? "1 finding" : `${sentFindingCount} findings`;
|
||||
console.log(chalk.green(`Sent ${findings} to ${chalk.cyan(run.linkedSessionId)}:`));
|
||||
printRun(run);
|
||||
}
|
||||
|
||||
function getRunProjectId(
|
||||
projectIds: string[],
|
||||
runId: string,
|
||||
): { projectId: string; run: CodeReviewRunSummary } | null {
|
||||
for (const projectId of projectIds) {
|
||||
const run = createCodeReviewStore(projectId)
|
||||
.listRunSummaries()
|
||||
.find((entry) => entry.id === runId || entry.reviewerSessionId === runId);
|
||||
if (run) return { projectId, run };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNextQueuedRun(
|
||||
projectIds: string[],
|
||||
): { projectId: string; run: CodeReviewRunSummary } | null {
|
||||
return (
|
||||
projectIds
|
||||
.flatMap((projectId) =>
|
||||
createCodeReviewStore(projectId)
|
||||
.listRunSummaries({ status: "queued" })
|
||||
.map((run) => ({ projectId, run })),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.run.createdAt.localeCompare(b.run.createdAt) ||
|
||||
a.projectId.localeCompare(b.projectId) ||
|
||||
a.run.id.localeCompare(b.run.id),
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function registerReview(program: Command): void {
|
||||
const review = program.command("review").description("Manage AO-local reviewer runs");
|
||||
|
||||
review
|
||||
.command("run")
|
||||
.description("Request a reviewer run for a worker session")
|
||||
.argument("<session>", "Worker session ID")
|
||||
.option("--summary <text>", "Summary to store on the review run")
|
||||
.option("--status <status>", "Initial run status (defaults to queued)")
|
||||
.option("--execute", "Execute the review run immediately")
|
||||
.option("--command <command>", "Shell command to execute as the reviewer")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (
|
||||
sessionId: string,
|
||||
opts: {
|
||||
summary?: string;
|
||||
status?: string;
|
||||
execute?: boolean;
|
||||
command?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const sessionManager = await getSessionManager(config);
|
||||
let run = await triggerCodeReviewForSession(
|
||||
{ config, sessionManager },
|
||||
{
|
||||
sessionId,
|
||||
requestedBy: "cli",
|
||||
status: parseRunStatus(opts.status),
|
||||
summary: opts.summary,
|
||||
},
|
||||
);
|
||||
|
||||
if (opts.execute || opts.command) {
|
||||
run = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}),
|
||||
},
|
||||
{ projectId: run.projectId, runId: run.id },
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ run }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
opts.execute || opts.command ? "Review run executed:" : "Review run requested:",
|
||||
),
|
||||
);
|
||||
printRun(run);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
console.error(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
review
|
||||
.command("execute")
|
||||
.description("Execute a queued AO-local reviewer run")
|
||||
.argument("[project]", "Project ID (searches all projects if omitted)")
|
||||
.option("--run <run>", "Review run ID or reviewer session ID")
|
||||
.option("--command <command>", "Shell command to execute as the reviewer")
|
||||
.option("--force", "Execute even if the run is not queued")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (
|
||||
projectId: string | undefined,
|
||||
opts: { run?: string; command?: string; force?: boolean; json?: boolean },
|
||||
) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (projectId && !config.projects[projectId]) {
|
||||
throw new Error(`Unknown project: ${projectId}`);
|
||||
}
|
||||
|
||||
const projectIds = projectId ? [projectId] : Object.keys(config.projects);
|
||||
const target = opts.run
|
||||
? getRunProjectId(projectIds, opts.run)
|
||||
: getNextQueuedRun(projectIds);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
opts.run ? `Review run not found: ${opts.run}` : "No queued review runs found.",
|
||||
);
|
||||
}
|
||||
|
||||
const sessionManager = await getSessionManager(config);
|
||||
const run = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
force: opts.force,
|
||||
...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}),
|
||||
},
|
||||
{ projectId: target.projectId, runId: target.run.id },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ run }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green("Review run executed:"));
|
||||
printRun(run);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
console.error(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
review
|
||||
.command("send")
|
||||
.description("Send open AO-local review findings to the linked coding worker")
|
||||
.argument("<run>", "Review run ID or reviewer session ID")
|
||||
.option("-p, --project <project>", "Project ID (searches all projects if omitted)")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (runRef: string, opts: { project?: string; json?: boolean }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (opts.project && !config.projects[opts.project]) {
|
||||
throw new Error(`Unknown project: ${opts.project}`);
|
||||
}
|
||||
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
const target = getRunProjectId(projectIds, runRef);
|
||||
if (!target) {
|
||||
throw new Error(`Review run not found: ${runRef}`);
|
||||
}
|
||||
|
||||
const sessionManager = await getSessionManager(config);
|
||||
const result = await sendCodeReviewFindingsToAgent(
|
||||
{ config, sessionManager },
|
||||
{ projectId: target.projectId, runId: target.run.id },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printSendResult(result.run, result.sentFindingCount);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
console.error(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
review
|
||||
.command("list")
|
||||
.description("List AO-local reviewer runs")
|
||||
.argument("[project]", "Project ID (lists all projects if omitted)")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (projectId: string | undefined, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (projectId && !config.projects[projectId]) {
|
||||
throw new Error(`Unknown project: ${projectId}`);
|
||||
}
|
||||
|
||||
const projectIds = projectId ? [projectId] : Object.keys(config.projects);
|
||||
const runs = projectIds.flatMap((id) => createCodeReviewStore(id).listRunSummaries());
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ runs }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
console.log(chalk.dim("No review runs found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
printRun(run);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ async function resolveSessionContext(sessionName: string): Promise<{
|
|||
if (session) {
|
||||
const tmuxTarget = session.runtimeHandle?.id ?? sessionName;
|
||||
const project = config.projects[session.projectId];
|
||||
const agentName = session.metadata["agent"] ?? project?.agent ?? config.defaults.agent;
|
||||
const agentName = session.metadata["agent"]!;
|
||||
const runtimeName =
|
||||
session.runtimeHandle?.runtimeName ?? project?.runtime ?? config.defaults.runtime;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,492 +1,47 @@
|
|||
/**
|
||||
* `ao setup openclaw` — interactive wizard + non-interactive mode
|
||||
* for wiring AO notifications to an OpenClaw gateway.
|
||||
*
|
||||
* Interactive: ao setup openclaw (human in terminal)
|
||||
* Programmatic: ao setup openclaw --url X --token Y --non-interactive
|
||||
* (OpenClaw agent calling via exec tool)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { parse as yamlParse, parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
import {
|
||||
probeGateway,
|
||||
validateToken,
|
||||
detectOpenClawInstallation,
|
||||
DEFAULT_OPENCLAW_URL,
|
||||
HOOKS_PATH,
|
||||
} from "../lib/openclaw-probe.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SetupAbortedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SetupAbortedError";
|
||||
}
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
url?: string;
|
||||
token?: string;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
}
|
||||
|
||||
type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all";
|
||||
|
||||
interface ResolvedConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
routingPreset: OpenClawRoutingPreset;
|
||||
}
|
||||
|
||||
const OPENCLAW_ROUTING_PRESETS = {
|
||||
"urgent-only": ["urgent"],
|
||||
"urgent-action": ["urgent", "action"],
|
||||
all: ["urgent", "action", "warning", "info"],
|
||||
} as const;
|
||||
|
||||
const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset {
|
||||
return value === "urgent-only" || value === "urgent-action" || value === "all";
|
||||
}
|
||||
|
||||
function normalizeOpenClawHooksUrl(url: string): string {
|
||||
const normalized = url.trim().replace(/\/+$/, "");
|
||||
return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive prompts (dynamic import to keep @clack/prompts optional)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function interactiveSetup(existingUrl?: string): Promise<ResolvedConfig> {
|
||||
const clack = await import("@clack/prompts");
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw ")));
|
||||
|
||||
// --- Step 1: Gateway URL ---------------------------------------------------
|
||||
const defaultUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`;
|
||||
let detectedUrl: string | undefined;
|
||||
|
||||
const spin = clack.spinner();
|
||||
spin.start("Detecting OpenClaw gateway on localhost...");
|
||||
|
||||
const probe = await probeGateway(DEFAULT_OPENCLAW_URL);
|
||||
if (probe.reachable) {
|
||||
detectedUrl = defaultUrl;
|
||||
spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`);
|
||||
} else {
|
||||
spin.stop("No OpenClaw gateway detected on localhost");
|
||||
}
|
||||
|
||||
const urlInput = await clack.text({
|
||||
message: "OpenClaw webhook URL:",
|
||||
placeholder: defaultUrl,
|
||||
initialValue: existingUrl ?? detectedUrl ?? defaultUrl,
|
||||
validate: (v) => {
|
||||
if (!v) return "URL is required";
|
||||
if (!v.startsWith("http://") && !v.startsWith("https://"))
|
||||
return "Must start with http:// or https://";
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(urlInput)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
// Normalize: ensure URL ends with /hooks/agent
|
||||
const url = normalizeOpenClawHooksUrl(urlInput as string);
|
||||
|
||||
// --- Step 2: Token ---------------------------------------------------------
|
||||
const envToken = process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
let tokenValue: string;
|
||||
|
||||
if (envToken) {
|
||||
const useEnv = await clack.confirm({
|
||||
message: `Found OPENCLAW_HOOKS_TOKEN in environment. Use it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (clack.isCancel(useEnv)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
if (useEnv) {
|
||||
tokenValue = envToken;
|
||||
} else {
|
||||
const input = await clack.password({
|
||||
message: "Enter your OpenClaw hooks token:",
|
||||
validate: (v) => (!v ? "Token is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(input)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
tokenValue = input as string;
|
||||
}
|
||||
} else {
|
||||
const generatedToken = randomBytes(32).toString("base64url");
|
||||
const tokenChoice = await clack.select({
|
||||
message: "How would you like to set the hooks token?",
|
||||
options: [
|
||||
{ value: "generate", label: "Auto-generate a secure token (recommended)" },
|
||||
{ value: "manual", label: "Enter an existing token manually" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(tokenChoice)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
if (tokenChoice === "manual") {
|
||||
const input = await clack.password({
|
||||
message: "Enter your OpenClaw hooks token:",
|
||||
validate: (v) => (!v ? "Token is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(input)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
tokenValue = input as string;
|
||||
} else {
|
||||
tokenValue = generatedToken;
|
||||
clack.log.success(`Generated token: ${chalk.dim(tokenValue.slice(0, 8))}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 3: Validate ------------------------------------------------------
|
||||
spin.start("Validating token against gateway...");
|
||||
|
||||
const validation = await validateToken(url, tokenValue);
|
||||
if (validation.valid) {
|
||||
spin.stop("Token validated — connection works!");
|
||||
} else {
|
||||
spin.stop(`Validation failed: ${validation.error}`);
|
||||
|
||||
const cont = await clack.confirm({
|
||||
message: "Save config anyway? (you can fix the token later)",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(cont) || !cont) {
|
||||
clack.cancel("Setup cancelled. Fix the issue and retry.");
|
||||
throw new SetupAbortedError("Setup cancelled. Fix the issue and retry.");
|
||||
}
|
||||
}
|
||||
|
||||
const routingPreset = await clack.select({
|
||||
message: "Which notifications should OpenClaw receive?",
|
||||
initialValue: "urgent-action",
|
||||
options: [
|
||||
{ value: "urgent-action", label: "Urgent + action (recommended)" },
|
||||
{ value: "urgent-only", label: "Urgent only" },
|
||||
{ value: "all", label: "All priorities" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(routingPreset)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
token: tokenValue,
|
||||
routingPreset: routingPreset as OpenClawRoutingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-interactive path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig> {
|
||||
let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"];
|
||||
const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
|
||||
// Auto-detect OpenClaw if no URL was given explicitly
|
||||
if (!rawUrl) {
|
||||
const installation = await detectOpenClawInstallation();
|
||||
if (installation.state === "running") {
|
||||
rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`;
|
||||
console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`));
|
||||
} else {
|
||||
throw new SetupAbortedError(
|
||||
"Error: OpenClaw gateway not reachable and no --url provided.\n" +
|
||||
" Start OpenClaw first, or pass --url explicitly:\n" +
|
||||
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let url = rawUrl;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
throw new SetupAbortedError("Error: --url must start with http:// or https://");
|
||||
}
|
||||
|
||||
// Normalize: ensure URL ends with /hooks/agent
|
||||
url = normalizeOpenClawHooksUrl(url);
|
||||
|
||||
const resolvedToken = token ?? randomBytes(32).toString("base64url");
|
||||
if (!token) {
|
||||
console.log(chalk.dim("No token provided — auto-generated a secure token."));
|
||||
}
|
||||
|
||||
// Skip pre-write validation — on fresh installs the gateway won't have the
|
||||
// token yet. We write both configs first, then the user restarts the gateway.
|
||||
console.log(chalk.dim("Skipping pre-validation (token will be written to both configs)."));
|
||||
|
||||
const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action";
|
||||
|
||||
return { url, token: resolvedToken, routingPreset };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writeOpenClawConfig(
|
||||
configPath: string,
|
||||
resolved: ResolvedConfig,
|
||||
nonInteractive: boolean,
|
||||
): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
|
||||
// Use parseDocument to preserve YAML comments during round-trip
|
||||
const doc = parseDocument(rawYaml);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawConfig = (doc.toJS() as Record<string, any>) ?? {};
|
||||
|
||||
// Write the env-var placeholder so the raw token is never committed to
|
||||
// version control. ao setup openclaw exports the real value to the shell
|
||||
// profile; the notifier plugin resolves it at runtime (env var → openclaw.json
|
||||
// fallback for daemon contexts where the shell profile isn't sourced).
|
||||
if (!rawConfig.notifiers) rawConfig.notifiers = {};
|
||||
rawConfig.notifiers.openclaw = {
|
||||
plugin: "openclaw",
|
||||
url: resolved.url,
|
||||
token: "$" + "{OPENCLAW_HOOKS_TOKEN}", // env-var placeholder, not a JS template
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
wakeMode: "now",
|
||||
};
|
||||
|
||||
// Add "openclaw" to defaults.notifiers if not already present
|
||||
if (!rawConfig.defaults) rawConfig.defaults = {};
|
||||
if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = [];
|
||||
if (!Array.isArray(rawConfig.defaults.notifiers)) {
|
||||
rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers];
|
||||
}
|
||||
if (!rawConfig.defaults.notifiers.includes("openclaw")) {
|
||||
rawConfig.defaults.notifiers.push("openclaw");
|
||||
}
|
||||
|
||||
const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter(
|
||||
(name) => name !== "openclaw",
|
||||
);
|
||||
|
||||
// AO routes notifications by priority. Preserve existing notifiers and only
|
||||
// adjust OpenClaw membership across the standard priority buckets.
|
||||
if (!rawConfig.notificationRouting) {
|
||||
const base = [...new Set(defaultsWithoutOpenClaw)];
|
||||
rawConfig.notificationRouting = {
|
||||
urgent: [...base],
|
||||
action: [...base],
|
||||
warning: [...base],
|
||||
info: [...base],
|
||||
};
|
||||
} else if (typeof rawConfig.notificationRouting === "object") {
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
if (!Array.isArray(rawConfig.notificationRouting[priority])) {
|
||||
rawConfig.notificationRouting[priority] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]);
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
const list = rawConfig.notificationRouting[priority] as string[];
|
||||
rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw");
|
||||
if (selectedPriorities.has(priority)) {
|
||||
rawConfig.notificationRouting[priority].push("openclaw");
|
||||
}
|
||||
}
|
||||
|
||||
// Update the document tree from the modified plain object while preserving comments
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
doc.setIn(["notifiers"], rawConfig.notifiers);
|
||||
doc.setIn(["defaults"], rawConfig.defaults);
|
||||
doc.setIn(["notificationRouting"], rawConfig.notificationRouting);
|
||||
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log(chalk.green(`✓ Config written to ${configPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the hooks block into ~/.openclaw/openclaw.json.
|
||||
* Returns true on success, false on failure (caller should fall back to
|
||||
* printing manual instructions).
|
||||
*/
|
||||
function writeOpenClawJsonConfig(token: string): boolean {
|
||||
try {
|
||||
const openclawDir = join(homedir(), ".openclaw");
|
||||
const openclawJsonPath = join(openclawDir, "openclaw.json");
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(openclawJsonPath)) {
|
||||
const raw = readFileSync(openclawJsonPath, "utf-8");
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} else if (!existsSync(openclawDir)) {
|
||||
mkdirSync(openclawDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Merge the hooks block (preserve other existing keys in hooks if any)
|
||||
const existingHooks = (config.hooks as Record<string, unknown> | undefined) ?? {};
|
||||
const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes)
|
||||
? existingHooks.allowedSessionKeyPrefixes.filter(
|
||||
(prefix): prefix is string => typeof prefix === "string",
|
||||
)
|
||||
: [];
|
||||
const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:")
|
||||
? existingPrefixes
|
||||
: [...existingPrefixes, "hook:"];
|
||||
|
||||
config.hooks = {
|
||||
...existingHooks,
|
||||
enabled: true,
|
||||
token,
|
||||
allowRequestSessionKey: true,
|
||||
allowedSessionKeyPrefixes,
|
||||
};
|
||||
|
||||
writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `export OPENCLAW_HOOKS_TOKEN=...` to the user's shell profile
|
||||
* (~/.zshrc or ~/.bashrc). Skips if the export line already exists.
|
||||
* Returns the profile path on success, undefined on failure.
|
||||
*/
|
||||
function writeShellExport(token: string): string | undefined {
|
||||
try {
|
||||
const shell = process.env["SHELL"] ?? "";
|
||||
const profileName = shell.endsWith("/zsh") ? ".zshrc" : ".bashrc";
|
||||
const profilePath = join(homedir(), profileName);
|
||||
|
||||
// Sanitize token: escape shell-special characters to prevent injection
|
||||
// when the profile is sourced. Single-quote the value and escape any
|
||||
// embedded single quotes (the only character that breaks '...' quoting).
|
||||
const safeToken = token.replace(/'/g, "'\\''");
|
||||
const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`;
|
||||
|
||||
// Check if it already exists (use the same regex for detection and replacement
|
||||
// to avoid silent no-ops when the line is commented, lacks the export prefix,
|
||||
// or has leading whitespace)
|
||||
// Negative lookahead excludes commented lines (e.g. # export OPENCLAW_HOOKS_TOKEN=...)
|
||||
const existingExportRegex = /^(?!\s*#)\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m;
|
||||
if (existsSync(profilePath)) {
|
||||
const content = readFileSync(profilePath, "utf-8");
|
||||
if (existingExportRegex.test(content)) {
|
||||
// Replace the existing line
|
||||
const updated = content.replace(existingExportRegex, exportLine);
|
||||
writeFileSync(profilePath, updated);
|
||||
return profilePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Append
|
||||
const prefix = existsSync(profilePath) ? "\n" : "";
|
||||
writeFileSync(profilePath, `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, {
|
||||
flag: "a",
|
||||
});
|
||||
return profilePath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function printOpenClawInstructions(
|
||||
nonInteractive: boolean,
|
||||
openclawConfigWritten: boolean,
|
||||
shellProfilePath: string | undefined,
|
||||
): void {
|
||||
if (openclawConfigWritten) {
|
||||
// Both configs written automatically
|
||||
if (nonInteractive) {
|
||||
console.log(
|
||||
chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"),
|
||||
);
|
||||
if (shellProfilePath) {
|
||||
console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`));
|
||||
}
|
||||
console.log("Restart OpenClaw gateway to apply.");
|
||||
} else {
|
||||
console.log(`\n${chalk.green.bold("Done — both configs written.")}`);
|
||||
console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block"));
|
||||
console.log(chalk.dim(" ~/.openclaw/openclaw.json — hooks block"));
|
||||
if (shellProfilePath) {
|
||||
console.log(chalk.dim(` ${shellProfilePath} — OPENCLAW_HOOKS_TOKEN export`));
|
||||
}
|
||||
console.log(`\n${chalk.yellow("Restart OpenClaw gateway to apply.")}`);
|
||||
}
|
||||
} else {
|
||||
// Fallback: could not write OpenClaw config, print manual instructions
|
||||
const instructions = `
|
||||
${chalk.bold("OpenClaw-side config required")}
|
||||
|
||||
AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}):
|
||||
|
||||
${chalk.cyan(`{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"token": "<same-token-you-entered-above>",
|
||||
"allowRequestSessionKey": true,
|
||||
"allowedSessionKeyPrefixes": ["hook:"]
|
||||
}
|
||||
}`)}
|
||||
`;
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log("\nOpenClaw-side config required:");
|
||||
console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:");
|
||||
console.log(" hooks.enabled: true");
|
||||
console.log(' hooks.token: "<your-token>"');
|
||||
console.log(" hooks.allowRequestSessionKey: true");
|
||||
console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]');
|
||||
} else {
|
||||
console.log(instructions);
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopSetupError,
|
||||
runDesktopSetupAction,
|
||||
type DesktopSetupOptions,
|
||||
} from "../lib/desktop-setup.js";
|
||||
import {
|
||||
DashboardSetupError,
|
||||
runDashboardSetupAction,
|
||||
type DashboardSetupOptions,
|
||||
} from "../lib/dashboard-setup.js";
|
||||
import {
|
||||
WebhookSetupError,
|
||||
runWebhookSetupAction,
|
||||
type WebhookSetupOptions,
|
||||
} from "../lib/webhook-setup.js";
|
||||
import {
|
||||
SlackSetupError,
|
||||
runSlackSetupAction,
|
||||
type SlackSetupOptions,
|
||||
} from "../lib/slack-setup.js";
|
||||
import {
|
||||
DiscordSetupError,
|
||||
runDiscordSetupAction,
|
||||
type DiscordSetupOptions,
|
||||
} from "../lib/discord-setup.js";
|
||||
import {
|
||||
ComposioSetupError,
|
||||
runComposioDiscordBotSetupAction,
|
||||
runComposioDiscordWebhookSetupAction,
|
||||
runComposioMailSetupAction,
|
||||
runComposioSlackSetupAction,
|
||||
runComposioSetupAction,
|
||||
type ComposioDiscordBotSetupOptions,
|
||||
type ComposioDiscordWebhookSetupOptions,
|
||||
type ComposioMailSetupOptions,
|
||||
type ComposioSetupOptions,
|
||||
} from "../lib/composio-setup.js";
|
||||
import {
|
||||
OpenClawSetupError,
|
||||
runOpenClawSetupAction,
|
||||
type OpenClawSetupOptions,
|
||||
} from "../lib/openclaw-setup.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
|
|
@ -495,24 +50,300 @@ AO config was written successfully. Add this to your OpenClaw config (${chalk.di
|
|||
export function registerSetup(program: Command): void {
|
||||
const setup = program.command("setup").description("Set up integrations with external services");
|
||||
|
||||
setup
|
||||
.command("dashboard")
|
||||
.description("Configure dashboard notification retention and routing")
|
||||
.option("--limit <count>", "Number of latest notifications to retain for the dashboard")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure dashboard notifier config")
|
||||
.option("--non-interactive", "Skip prompts")
|
||||
.option("--force", "Replace a conflicting notifiers.dashboard entry")
|
||||
.option("--status", "Show dashboard notifier setup status")
|
||||
.action(async (opts: DashboardSetupOptions) => {
|
||||
try {
|
||||
await runDashboardSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DashboardSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("desktop")
|
||||
.description("Install and configure the native macOS desktop notifier")
|
||||
.option("--backend <backend>", "Desktop backend: auto | ao-app | terminal-notifier | osascript")
|
||||
.option("--refresh", "Refresh/reconfigure desktop notifier config")
|
||||
.option("--dashboard-url <url>", "Dashboard URL to open from notifications")
|
||||
.option("--app-path <path>", "Custom AO Notifier.app install path")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--no-test", "Skip the setup test notification")
|
||||
.option("--non-interactive", "Skip prompts and fail on config conflicts unless --force is set")
|
||||
.option("--force", "Repair the app install and replace conflicting desktop notifier config")
|
||||
.option("--status", "Show the native desktop notifier install and permission status")
|
||||
.option("--uninstall", "Remove AO Notifier.app without changing AO config")
|
||||
.action(async (opts: DesktopSetupOptions) => {
|
||||
try {
|
||||
await runDesktopSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DesktopSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("webhook")
|
||||
.description("Connect AO notifications to a generic HTTP webhook")
|
||||
.option("--url <url>", "Webhook URL")
|
||||
.option("--auth-token <token>", "Bearer token to store in webhook Authorization header")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure webhook notifier config")
|
||||
.option("--no-test", "Skip the setup test POST")
|
||||
.option("--non-interactive", "Skip prompts and require --url unless --refresh can reuse config")
|
||||
.option("--force", "Replace a conflicting notifiers.webhook entry")
|
||||
.option("--status", "Show webhook notifier setup status and probe the endpoint")
|
||||
.action(async (opts: WebhookSetupOptions) => {
|
||||
try {
|
||||
await runWebhookSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof WebhookSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("slack")
|
||||
.description("Connect AO notifications to a Slack incoming webhook")
|
||||
.option("--webhook-url <url>", "Slack incoming webhook URL")
|
||||
.option(
|
||||
"--channel <name>",
|
||||
"Optional channel name; must match the channel selected for the webhook URL",
|
||||
)
|
||||
.option("--username <name>", "Slack display name for AO messages")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure Slack notifier config")
|
||||
.option("--no-test", "Skip the setup test Slack message")
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Skip prompts and require --webhook-url unless --refresh can reuse config",
|
||||
)
|
||||
.option("--force", "Replace a conflicting notifiers.slack entry")
|
||||
.option("--status", "Show Slack notifier setup status and probe the endpoint")
|
||||
.action(async (opts: SlackSetupOptions) => {
|
||||
try {
|
||||
await runSlackSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof SlackSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("discord")
|
||||
.description("Connect AO notifications to a Discord incoming webhook")
|
||||
.option("--webhook-url <url>", "Discord incoming webhook URL")
|
||||
.option("--username <name>", "Discord display name for AO messages")
|
||||
.option("--avatar-url <url>", "Discord avatar image URL for AO messages")
|
||||
.option("--thread-id <id>", "Discord thread id to post into")
|
||||
.option("--retries <count>", "Retry count for rate limits, network errors, and 5xx")
|
||||
.option("--retry-delay-ms <ms>", "Base retry delay in milliseconds")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure Discord notifier config")
|
||||
.option("--no-test", "Skip the setup test Discord message")
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Skip prompts and require --webhook-url unless --refresh can reuse config",
|
||||
)
|
||||
.option("--force", "Replace a conflicting notifiers.discord entry")
|
||||
.option("--status", "Show Discord notifier setup status and probe the endpoint")
|
||||
.action(async (opts: DiscordSetupOptions) => {
|
||||
try {
|
||||
await runDiscordSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DiscordSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio")
|
||||
.description("Open the interactive Composio notifier setup hub")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id")
|
||||
.option("--slack", "Open Slack setup directly")
|
||||
.option("--discord-webhook", "Open Discord webhook setup directly")
|
||||
.option("--discord-bot", "Open Discord bot setup directly")
|
||||
.option("--gmail", "Open Gmail setup directly")
|
||||
.option("--channel <name-or-id>", "Slack channel name or channel id for scriptable Slack setup")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option(
|
||||
"--connected-account-id <id>",
|
||||
"Existing Composio Slack connected account id for scriptable Slack setup",
|
||||
)
|
||||
.option(
|
||||
"--wait-ms <ms>",
|
||||
"How long to wait for a new Slack connection in scriptable setup",
|
||||
"60000",
|
||||
)
|
||||
.option("--non-interactive", "Skip prompts and fail when multiple accounts need selection")
|
||||
.option("--status", "Show Composio notifier setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio entry")
|
||||
.action(async (opts: ComposioSetupOptions) => {
|
||||
try {
|
||||
await runComposioSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-slack")
|
||||
.description("Connect AO notifications to Slack through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for the Slack connected account")
|
||||
.option("--channel <name-or-id>", "Slack channel name or channel id")
|
||||
.option("--connected-account-id <id>", "Existing Composio Slack connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--wait-ms <ms>", "How long to wait for a new Slack connection", "60000")
|
||||
.option("--non-interactive", "Skip prompts and fail when multiple accounts need selection")
|
||||
.option("--status", "Show Composio Slack setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-slack entry")
|
||||
.action(async (opts: ComposioSetupOptions) => {
|
||||
try {
|
||||
await runComposioSlackSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-discord")
|
||||
.description("Connect AO notifications to Discord webhooks through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for tool execution")
|
||||
.option("--webhook-url <url>", "Discord webhook URL")
|
||||
.option("--connected-account-id <id>", "Existing Composio Discord webhook connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--non-interactive", "Skip prompts")
|
||||
.option("--status", "Show Composio Discord webhook setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-discord entry")
|
||||
.action(async (opts: ComposioDiscordWebhookSetupOptions) => {
|
||||
try {
|
||||
await runComposioDiscordWebhookSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-discord-bot")
|
||||
.description("Connect AO notifications to a Discord bot through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for the Discord connected account")
|
||||
.option("--channel-id <id>", "Discord channel id")
|
||||
.option("--bot-token <token>", "Discord bot token used once to create the Composio account")
|
||||
.option("--connected-account-id <id>", "Existing Composio Discord bot connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--non-interactive", "Skip prompts")
|
||||
.option("--status", "Show Composio Discord bot setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-discord-bot entry")
|
||||
.action(async (opts: ComposioDiscordBotSetupOptions) => {
|
||||
try {
|
||||
await runComposioDiscordBotSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-mail")
|
||||
.description("Connect AO notifications to Gmail through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for the Gmail connected account")
|
||||
.option("--email-to <email>", "Recipient email address for AO notifications")
|
||||
.option("--connect", "Print a Composio Gmail connect URL when no account exists")
|
||||
.option("--auth-config-id <id>", "Existing Composio Gmail auth config id for --connect")
|
||||
.option("--connected-account-id <id>", "Existing Composio Gmail connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--wait-ms <ms>", "How long to wait for a new Gmail connection with --connect", "60000")
|
||||
.option("--non-interactive", "Skip prompts and fail when multiple accounts need selection")
|
||||
.option("--status", "Show Composio mail setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-mail entry")
|
||||
.action(async (opts: ComposioMailSetupOptions) => {
|
||||
try {
|
||||
await runComposioMailSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("openclaw")
|
||||
.description("Connect AO notifications to an OpenClaw gateway")
|
||||
.option("--url <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
|
||||
.option("--token <token>", "OpenClaw hooks auth token")
|
||||
.option(
|
||||
"--routing-preset <preset>",
|
||||
"OpenClaw routing preset: urgent-only | urgent-action | all",
|
||||
"--token <token>",
|
||||
"Remote/manual fallback token; local setup should read hooks.token from OpenClaw config",
|
||||
)
|
||||
.option("--openclaw-config-path <path>", "OpenClaw config path that contains hooks.token")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)",
|
||||
"Skip prompts — auto-detects OpenClaw if --url not provided and reads token from OpenClaw config",
|
||||
)
|
||||
.action(async (opts: SetupOptions) => {
|
||||
.option("--refresh", "Refresh/reconfigure OpenClaw notifier config")
|
||||
.option("--no-test", "Skip the setup token probe")
|
||||
.option("--force", "Replace a conflicting notifiers.openclaw entry")
|
||||
.option("--status", "Show OpenClaw notifier setup status and probe the gateway")
|
||||
.action(async (opts: OpenClawSetupOptions) => {
|
||||
try {
|
||||
await runSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof SetupAbortedError) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.setup_failed",
|
||||
level: "error",
|
||||
summary: "ao setup openclaw failed",
|
||||
data: {
|
||||
aborted: err instanceof OpenClawSetupError && err.exitCode === 0,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof OpenClawSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
|
|
@ -521,80 +352,6 @@ export function registerSetup(program: Command): void {
|
|||
});
|
||||
}
|
||||
|
||||
export async function runSetupAction(opts: SetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
|
||||
// --- Find existing config ------------------------------------------------
|
||||
let configPath: string | undefined;
|
||||
try {
|
||||
const found = findConfigFile();
|
||||
configPath = found ?? undefined;
|
||||
} catch {
|
||||
// no config found
|
||||
}
|
||||
|
||||
if (!configPath) {
|
||||
throw new SetupAbortedError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Check for existing openclaw config ----------------------------------
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const rawConfig = yamlParse(rawYaml) ?? {};
|
||||
const existingOpenClaw = rawConfig?.notifiers?.openclaw;
|
||||
const existingUrl = existingOpenClaw?.url as string | undefined;
|
||||
|
||||
if (existingOpenClaw && !nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
const reconfigure = await clack.confirm({
|
||||
message: "OpenClaw is already configured. Reconfigure?",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(reconfigure) || !reconfigure) {
|
||||
console.log(chalk.dim("Keeping existing config."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Run setup -----------------------------------------------------------
|
||||
let resolved: ResolvedConfig;
|
||||
|
||||
if (nonInteractive) {
|
||||
resolved = await nonInteractiveSetup(opts);
|
||||
} else {
|
||||
resolved = await interactiveSetup(existingUrl);
|
||||
}
|
||||
|
||||
// --- Write AO config -----------------------------------------------------
|
||||
writeOpenClawConfig(configPath, resolved, nonInteractive);
|
||||
|
||||
// --- Write OpenClaw config -----------------------------------------------
|
||||
const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token);
|
||||
if (openclawConfigWritten && nonInteractive) {
|
||||
console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json"));
|
||||
}
|
||||
|
||||
// --- Write shell export --------------------------------------------------
|
||||
const shellProfilePath = writeShellExport(resolved.token);
|
||||
if (shellProfilePath && nonInteractive) {
|
||||
console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`));
|
||||
}
|
||||
|
||||
// --- Print instructions --------------------------------------------------
|
||||
printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath);
|
||||
|
||||
// --- Done ----------------------------------------------------------------
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` +
|
||||
chalk.dim(" Run 'ao doctor' to verify the full setup.\n") +
|
||||
chalk.dim(" Restart AO with 'ao stop && ao start' to activate."),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ OpenClaw setup complete."));
|
||||
console.log(chalk.dim("Restart AO to activate: ao stop && ao start"));
|
||||
}
|
||||
export async function runSetupAction(opts: OpenClawSetupOptions): Promise<void> {
|
||||
await runOpenClawSetupAction(opts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Command } from "commander";
|
|||
import { resolve } from "node:path";
|
||||
import {
|
||||
loadConfig,
|
||||
recordActivityEvent,
|
||||
resolveSpawnTarget,
|
||||
TERMINAL_STATUSES,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -216,6 +217,20 @@ async function spawnSession(
|
|||
throw new Error("Prompt must be at most 4096 characters");
|
||||
}
|
||||
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_invoked",
|
||||
level: "info",
|
||||
summary: `ao spawn invoked${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: agent ?? null,
|
||||
hasPrompt: !!sanitizedPrompt,
|
||||
claimPr: claimOptions?.claimPr ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await sm.spawn({
|
||||
projectId,
|
||||
issueId,
|
||||
|
|
@ -243,9 +258,7 @@ async function spawnSession(
|
|||
const issueLabel = issueId ? ` for issue #${issueId}` : "";
|
||||
const claimLabel = claimedPrUrl ? ` (claimed ${claimedPrUrl})` : "";
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
spinner.succeed(
|
||||
`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`,
|
||||
);
|
||||
spinner.succeed(`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`);
|
||||
console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`);
|
||||
|
||||
// Open terminal tab if requested
|
||||
|
|
@ -262,6 +275,18 @@ async function spawnSession(
|
|||
console.log(`SESSION=${session.id}`);
|
||||
} catch (err) {
|
||||
spinner.fail("Failed to create or initialize session");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `ao spawn failed${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: agent ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -279,7 +304,10 @@ export function registerSpawn(program: Command): void {
|
|||
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
|
||||
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")
|
||||
.option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user")
|
||||
.option("--prompt <text>", "Initial prompt/instructions for the agent (use instead of an issue)")
|
||||
.option(
|
||||
"--prompt <text>",
|
||||
"Initial prompt/instructions for the agent (use instead of an issue)",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
issue: string | undefined,
|
||||
|
|
@ -326,8 +354,34 @@ export function registerSpawn(program: Command): void {
|
|||
try {
|
||||
await runSpawnPreflight(config, projectId, claimOptions);
|
||||
await ensureAOPollingProject(projectId);
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `ao spawn preflight failed${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: opts.agent ?? null,
|
||||
claimPr: claimOptions.claimPr ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt);
|
||||
try {
|
||||
await spawnSession(
|
||||
config,
|
||||
projectId,
|
||||
issueId,
|
||||
opts.open,
|
||||
opts.agent,
|
||||
claimOptions,
|
||||
opts.prompt,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
|
|
@ -386,9 +440,7 @@ export function registerBatchSpawn(program: Command): void {
|
|||
console.log(banner("BATCH SESSION SPAWNER"));
|
||||
console.log();
|
||||
for (const [pid, items] of groups) {
|
||||
console.log(
|
||||
` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`,
|
||||
);
|
||||
console.log(` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
|
|
@ -404,6 +456,17 @@ export function registerBatchSpawn(program: Command): void {
|
|||
await runSpawnPreflight(config, groupProjectId);
|
||||
await ensureAOPollingProject(groupProjectId);
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: groupProjectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `batch-spawn preflight failed for group`,
|
||||
data: {
|
||||
batchSize: items.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
findPidByPort,
|
||||
killProcessTree,
|
||||
loadLocalProjectConfigDetailed,
|
||||
recordActivityEvent,
|
||||
registerProjectInGlobalConfig,
|
||||
getGlobalConfigPath,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -86,6 +87,7 @@ import {
|
|||
type DetectedAgent,
|
||||
} from "../lib/detect-agent.js";
|
||||
import { detectDefaultBranch } from "../lib/git-utils.js";
|
||||
import { dashboardUrl } from "../lib/dashboard-url.js";
|
||||
import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js";
|
||||
import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js";
|
||||
import {
|
||||
|
|
@ -116,6 +118,17 @@ import { projectSessionUrl } from "../lib/routes.js";
|
|||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
class CliFailureEventRecordedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "CliFailureEventRecordedError";
|
||||
}
|
||||
}
|
||||
|
||||
function isCliFailureEventRecordedError(err: unknown): boolean {
|
||||
return err instanceof CliFailureEventRecordedError;
|
||||
}
|
||||
|
||||
function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig {
|
||||
const localConfig = loadLocalProjectConfigDetailed(projectPath);
|
||||
if (localConfig.kind === "loaded") {
|
||||
|
|
@ -162,6 +175,15 @@ async function registerFlatConfig(configPath: string): Promise<string | null> {
|
|||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
recordActivityEvent({
|
||||
projectId: registeredProjectId,
|
||||
source: "cli",
|
||||
kind: "cli.config_migrated",
|
||||
level: "info",
|
||||
summary: `flat config registered into global config`,
|
||||
data: { projectPath, configPath },
|
||||
});
|
||||
|
||||
console.log(chalk.green(` ✓ Registered "${registeredProjectId}"\n`));
|
||||
return registeredProjectId;
|
||||
}
|
||||
|
|
@ -856,7 +878,14 @@ async function runStartup(
|
|||
config: OrchestratorConfig,
|
||||
projectId: string,
|
||||
project: ProjectConfig,
|
||||
opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; dev?: boolean },
|
||||
opts?: {
|
||||
dashboard?: boolean;
|
||||
orchestrator?: boolean;
|
||||
rebuild?: boolean;
|
||||
dev?: boolean;
|
||||
/** true = restore without prompting, false = skip restore, undefined = prompt for humans */
|
||||
restore?: boolean;
|
||||
},
|
||||
): Promise<number> {
|
||||
await runtimePreflight(config);
|
||||
|
||||
|
|
@ -915,7 +944,7 @@ async function runStartup(
|
|||
config.directTerminalPort,
|
||||
opts?.dev,
|
||||
);
|
||||
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
|
||||
spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`);
|
||||
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
||||
}
|
||||
|
||||
|
|
@ -940,10 +969,21 @@ async function runStartup(
|
|||
}
|
||||
} catch (err) {
|
||||
spinner.fail("Orchestrator setup failed");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `orchestrator setup failed`,
|
||||
data: {
|
||||
reason: "orchestrator_setup",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
throw new CliFailureEventRecordedError(
|
||||
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
|
|
@ -953,33 +993,56 @@ async function runStartup(
|
|||
if (shouldStartLifecycle) {
|
||||
try {
|
||||
spinner.start("Starting project supervisor");
|
||||
await startProjectSupervisor();
|
||||
await startProjectSupervisor({ configPath: config.configPath });
|
||||
spinner.succeed("Lifecycle project supervisor started");
|
||||
} catch (err) {
|
||||
spinner.fail("Project supervisor failed to start");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `project supervisor failed to start`,
|
||||
data: {
|
||||
reason: "supervisor_start",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
throw new CliFailureEventRecordedError(
|
||||
`Failed to start project supervisor: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for sessions from last `ao stop` and offer to restore them
|
||||
if (isHumanCaller()) {
|
||||
// Check for sessions from last `ao stop` and restore/prompt/skip based on caller intent.
|
||||
if (opts?.restore !== false && (opts?.restore === true || isHumanCaller())) {
|
||||
try {
|
||||
const lastStop = await readLastStop();
|
||||
if (lastStop && lastStop.sessionIds.length > 0) {
|
||||
const totalLastStopSessions =
|
||||
(lastStop?.sessionIds.length ?? 0) +
|
||||
(lastStop?.otherProjects ?? []).reduce((sum, p) => sum + p.sessionIds.length, 0);
|
||||
if (lastStop && totalLastStopSessions > 0) {
|
||||
const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`;
|
||||
const otherProjects = lastStop.otherProjects ?? [];
|
||||
const restoreProjectBySessionId = new Map<string, string>();
|
||||
|
||||
// Build flat list of all sessions to restore, grouped for display
|
||||
const allRestoreSessions: string[] = [
|
||||
...(lastStop.projectId === projectId ? lastStop.sessionIds : []),
|
||||
...otherProjects.flatMap((p) => p.sessionIds),
|
||||
];
|
||||
for (const sessionId of lastStop.sessionIds) {
|
||||
restoreProjectBySessionId.set(sessionId, lastStop.projectId);
|
||||
}
|
||||
for (const otherProject of otherProjects) {
|
||||
for (const sessionId of otherProject.sessionIds) {
|
||||
restoreProjectBySessionId.set(sessionId, otherProject.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Display grouped by project
|
||||
const currentProjectSessions = lastStop.projectId === projectId ? lastStop.sessionIds : [];
|
||||
|
|
@ -1003,8 +1066,20 @@ async function runStartup(
|
|||
}
|
||||
|
||||
if (allRestoreSessions.length > 0) {
|
||||
const shouldRestore = await promptConfirm("Restore these sessions?", true);
|
||||
const shouldRestore =
|
||||
opts?.restore === true ? true : await promptConfirm("Restore these sessions?", true);
|
||||
if (shouldRestore) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_started",
|
||||
level: "info",
|
||||
summary: `restoring ${allRestoreSessions.length} session(s) from last-stop`,
|
||||
data: {
|
||||
sessionCount: allRestoreSessions.length,
|
||||
stoppedAt: lastStop.stoppedAt,
|
||||
},
|
||||
});
|
||||
// Use global config so the session manager can see all projects
|
||||
let restoreConfig = config;
|
||||
if (otherProjects.length > 0) {
|
||||
|
|
@ -1029,11 +1104,33 @@ async function runStartup(
|
|||
restoredCount++;
|
||||
} catch (err) {
|
||||
failedSessionIds.add(sessionId);
|
||||
const restoreProjectId = restoreProjectBySessionId.get(sessionId) ?? projectId;
|
||||
recordActivityEvent({
|
||||
projectId: restoreProjectId,
|
||||
sessionId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_session_failed",
|
||||
level: "warn",
|
||||
summary: `failed to restore session`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
warnings.push(
|
||||
` Warning: could not restore ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_completed",
|
||||
level: "info",
|
||||
summary: `restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
data: {
|
||||
requested: allRestoreSessions.length,
|
||||
restored: restoredCount,
|
||||
failed: failedSessionIds.size,
|
||||
},
|
||||
});
|
||||
if (restoredCount === allRestoreSessions.length) {
|
||||
restoreSpinner.succeed(
|
||||
`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
|
|
@ -1086,7 +1183,15 @@ async function runStartup(
|
|||
await clearLastStop();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_read_failed",
|
||||
level: "warn",
|
||||
summary: `failed to read or process last-stop state during startup`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
// Non-fatal: don't block startup if last-stop handling fails
|
||||
}
|
||||
}
|
||||
|
|
@ -1095,7 +1200,7 @@ async function runStartup(
|
|||
console.log(chalk.bold.green("\n✓ Startup complete\n"));
|
||||
|
||||
if (opts?.dashboard !== false) {
|
||||
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
|
||||
console.log(chalk.cyan("Dashboard:"), dashboardUrl(port));
|
||||
}
|
||||
|
||||
if (shouldStartLifecycle) {
|
||||
|
|
@ -1129,7 +1234,7 @@ async function runStartup(
|
|||
openAbort = new AbortController();
|
||||
const orchestratorUrl = selectedOrchestratorId
|
||||
? projectSessionUrl(port, projectId, selectedOrchestratorId)
|
||||
: `http://localhost:${port}`;
|
||||
: dashboardUrl(port);
|
||||
void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal);
|
||||
}
|
||||
|
||||
|
|
@ -1324,10 +1429,10 @@ async function attachAndSpawnOrchestrator(opts: {
|
|||
}
|
||||
|
||||
if (isHumanCaller()) {
|
||||
console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`));
|
||||
openUrl(`http://localhost:${daemon.port}`);
|
||||
console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`));
|
||||
openUrl(dashboardUrl(daemon.port));
|
||||
} else {
|
||||
console.log(`Dashboard: http://localhost:${daemon.port}`);
|
||||
console.log(`Dashboard: ${dashboardUrl(daemon.port)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1347,6 +1452,8 @@ export function registerStart(program: Command): void {
|
|||
.option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)")
|
||||
.option("--interactive", "Prompt to configure config settings")
|
||||
.option("--reap-orphans", "Kill orphaned AO child processes before starting")
|
||||
.option("--restore", "Restore sessions from last ao stop without prompting")
|
||||
.option("--no-restore", "Skip restoring sessions from last ao stop")
|
||||
.action(
|
||||
async (
|
||||
projectArg?: string,
|
||||
|
|
@ -1357,8 +1464,24 @@ export function registerStart(program: Command): void {
|
|||
dev?: boolean;
|
||||
interactive?: boolean;
|
||||
reapOrphans?: boolean;
|
||||
restore?: boolean;
|
||||
},
|
||||
) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.start_invoked",
|
||||
level: "info",
|
||||
summary: "ao start invoked",
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
dashboard: opts?.dashboard !== false,
|
||||
orchestrator: opts?.orchestrator !== false,
|
||||
rebuild: opts?.rebuild === true,
|
||||
dev: opts?.dev === true,
|
||||
interactive: opts?.interactive === true,
|
||||
},
|
||||
});
|
||||
|
||||
let releaseStartupLock: (() => void) | undefined;
|
||||
let startupLockReleased = false;
|
||||
const unlockStartup = (): void => {
|
||||
|
|
@ -1394,7 +1517,7 @@ export function registerStart(program: Command): void {
|
|||
// exit. Project-id args fall through to attach+spawn so
|
||||
// automation can `ao start <id>` against a live daemon.
|
||||
console.log(`AO is already running.`);
|
||||
console.log(`Dashboard: http://localhost:${running.port}`);
|
||||
console.log(`Dashboard: ${dashboardUrl(running.port)}`);
|
||||
console.log(`PID: ${running.pid}`);
|
||||
console.log(`Projects: ${running.projects.join(", ")}`);
|
||||
console.log(`To restart: ao stop && ao start`);
|
||||
|
|
@ -1404,7 +1527,7 @@ export function registerStart(program: Command): void {
|
|||
|
||||
if (isHumanCaller() && !projectArg) {
|
||||
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
||||
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
|
||||
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
||||
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
|
||||
console.log(` Projects: ${running.projects.join(", ")}\n`);
|
||||
|
||||
|
|
@ -1451,7 +1574,7 @@ export function registerStart(program: Command): void {
|
|||
);
|
||||
|
||||
if (choice === "open") {
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
} else if (choice === "quit") {
|
||||
|
|
@ -1483,7 +1606,7 @@ export function registerStart(program: Command): void {
|
|||
),
|
||||
);
|
||||
}
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
} else if (choice === "new") {
|
||||
|
|
@ -1491,6 +1614,13 @@ export function registerStart(program: Command): void {
|
|||
// Resolve happens below; the suffix mutation runs after.
|
||||
startNewOrchestrator = true;
|
||||
} else if (choice === "restart") {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.daemon_restart",
|
||||
level: "info",
|
||||
summary: `user chose restart, killing existing daemon`,
|
||||
data: { existingPid: running.pid, existingPort: running.port },
|
||||
});
|
||||
await killExistingDaemon(running);
|
||||
console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n"));
|
||||
running = null;
|
||||
|
|
@ -1562,9 +1692,9 @@ export function registerStart(program: Command): void {
|
|||
running.projects.includes(projectId)
|
||||
) {
|
||||
console.log(chalk.cyan(`\nℹ AO is already running.`));
|
||||
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
|
||||
console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`);
|
||||
console.log(` Project "${projectId}" is already registered and running.\n`);
|
||||
openUrl(`http://localhost:${running.port}`);
|
||||
openUrl(dashboardUrl(running.port));
|
||||
unlockStartup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -1649,6 +1779,18 @@ export function registerStart(program: Command): void {
|
|||
// Ctrl+C and `ao stop` (which sends SIGTERM) perform a full
|
||||
// graceful shutdown via the handler installed inside runStartup().
|
||||
} catch (err) {
|
||||
if (!isCliFailureEventRecordedError(err)) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `ao start action failed`,
|
||||
data: {
|
||||
reason: "outer",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
|
|
@ -1719,7 +1861,19 @@ export function registerStop(program: Command): void {
|
|||
.description("Stop orchestrator agent and dashboard")
|
||||
.option("--purge-session", "Delete mapped OpenCode session when stopping")
|
||||
.option("--all", "Stop all running AO instances")
|
||||
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => {
|
||||
.option("-y, --yes", "Confirm stopping active sessions without prompting")
|
||||
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {}) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_invoked",
|
||||
level: "info",
|
||||
summary: "ao stop invoked",
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
all: opts.all === true,
|
||||
purgeSession: opts.purgeSession === true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Check running.json first
|
||||
const running = await getRunning();
|
||||
|
|
@ -1757,10 +1911,30 @@ export function registerStop(program: Command): void {
|
|||
config = loadConfig(globalPath);
|
||||
}
|
||||
}
|
||||
const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop");
|
||||
let _projectId: string;
|
||||
let project: ProjectConfig;
|
||||
if (projectArg) {
|
||||
({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"));
|
||||
} else {
|
||||
const projectIds = Object.keys(config.projects);
|
||||
if (projectIds.length === 0) {
|
||||
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
|
||||
}
|
||||
const currentDir = resolve(cwd());
|
||||
const cwdProjectId = findProjectForDirectory(config.projects, currentDir);
|
||||
_projectId =
|
||||
running?.projects.find((id) => config.projects[id]) ??
|
||||
cwdProjectId ??
|
||||
projectIds[0];
|
||||
project = config.projects[_projectId];
|
||||
}
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
|
||||
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
|
||||
if (projectArg) {
|
||||
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
|
||||
} else {
|
||||
console.log(chalk.bold(`\nStopping AO across all projects\n`));
|
||||
}
|
||||
|
||||
const sm = await getSessionManager(config);
|
||||
try {
|
||||
|
|
@ -1786,6 +1960,16 @@ export function registerStop(program: Command): void {
|
|||
const otherByProject = new Map<string, string[]>();
|
||||
|
||||
if (activeSessions.length > 0) {
|
||||
if (!projectArg && opts.yes !== true && isHumanCaller()) {
|
||||
const confirmed = await promptConfirm(
|
||||
`Stop AO and ${activeSessions.length} active session(s)?`,
|
||||
false,
|
||||
);
|
||||
if (!confirmed) {
|
||||
console.log(chalk.yellow("Stop cancelled."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start();
|
||||
const purgeOpenCode = opts?.purgeSession === true;
|
||||
const warnings: string[] = [];
|
||||
|
|
@ -1796,6 +1980,15 @@ export function registerStop(program: Command): void {
|
|||
killedSessionIds.push(session.id);
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId ?? _projectId,
|
||||
sessionId: session.id,
|
||||
source: "cli",
|
||||
kind: "cli.stop_session_failed",
|
||||
level: "warn",
|
||||
summary: `failed to kill session during ao stop`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
warnings.push(
|
||||
` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
|
|
@ -1840,12 +2033,48 @@ export function registerStop(program: Command): void {
|
|||
otherProjects.push({ projectId: pid, sessionIds: ids });
|
||||
}
|
||||
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: _projectId,
|
||||
sessionIds: killedSessionIds.filter((id) => targetActive.some((s) => s.id === id)),
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
const targetSessionIds = killedSessionIds.filter((id) =>
|
||||
targetActive.some((s) => s.id === id),
|
||||
);
|
||||
try {
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: _projectId,
|
||||
sessionIds: targetSessionIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_written",
|
||||
level: "info",
|
||||
summary: `last-stop state written with ${killedSessionIds.length} session(s)`,
|
||||
data: {
|
||||
targetSessionCount: targetSessionIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_write_failed",
|
||||
level: "error",
|
||||
summary: `failed to write last-stop state during ao stop`,
|
||||
data: {
|
||||
targetSessionCount: targetSessionIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
|
|
@ -1872,7 +2101,29 @@ export function registerStop(program: Command): void {
|
|||
// 0x800700e8). No-op on non-Windows.
|
||||
await sweepWindowsPtyHostsBeforeParentKill();
|
||||
await sweepRegisteredDaemonChildren(running.pid);
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
try {
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.daemon_killed",
|
||||
level: "info",
|
||||
summary: `SIGTERM sent to parent ao start`,
|
||||
data: { pid: running.pid, port: running.port },
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.daemon_killed",
|
||||
level: "warn",
|
||||
summary: `parent ao start was already dead`,
|
||||
data: {
|
||||
pid: running.pid,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
await unregister();
|
||||
} else {
|
||||
await sweepRegisteredDaemonChildren();
|
||||
|
|
@ -1892,6 +2143,16 @@ export function registerStop(program: Command): void {
|
|||
console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`));
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_failed",
|
||||
level: "error",
|
||||
summary: `ao stop action failed`,
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { Command } from "commander";
|
|||
import {
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
type Agent,
|
||||
type SCM,
|
||||
type Session,
|
||||
type PRInfo,
|
||||
|
|
@ -13,12 +12,16 @@ import {
|
|||
type Tracker,
|
||||
type ProjectConfig,
|
||||
type AgentReportAuditEntry,
|
||||
type PluginRegistry,
|
||||
isOrchestratorSession,
|
||||
isTerminalSession,
|
||||
isWindows,
|
||||
loadConfig,
|
||||
getProjectSessionsDir,
|
||||
readAgentReportAuditTrailAsync,
|
||||
createCodeReviewStore,
|
||||
type CodeReviewRunStatus,
|
||||
type CodeReviewRunSummary,
|
||||
} from "@aoagents/ao-core";
|
||||
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
|
||||
import {
|
||||
|
|
@ -61,6 +64,24 @@ interface StatusOptions {
|
|||
reports?: string;
|
||||
}
|
||||
|
||||
interface ProjectReviewStatus {
|
||||
projectId: string;
|
||||
runs: CodeReviewRunSummary[];
|
||||
runCount: number;
|
||||
activeRunCount: number;
|
||||
openFindingCount: number;
|
||||
}
|
||||
|
||||
const REVIEW_ATTENTION_STATUSES: ReadonlySet<CodeReviewRunStatus> = new Set([
|
||||
"queued",
|
||||
"preparing",
|
||||
"running",
|
||||
"needs_triage",
|
||||
"sent_to_agent",
|
||||
"waiting_update",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/** Parse --reports value: "full" → Infinity, positive integer → N, undefined → 0 (off). */
|
||||
function parseReportsLimit(value: string | undefined): number {
|
||||
if (value === undefined) return 0;
|
||||
|
|
@ -89,9 +110,24 @@ function maybeClearScreen(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function gatherProjectReviewStatus(projectId: string): ProjectReviewStatus {
|
||||
const runs = createCodeReviewStore(projectId).listRunSummaries();
|
||||
const activeRunCount = runs.filter(
|
||||
(run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0,
|
||||
).length;
|
||||
const openFindingCount = runs.reduce((sum, run) => sum + run.openFindingCount, 0);
|
||||
return {
|
||||
projectId,
|
||||
runs,
|
||||
runCount: runs.length,
|
||||
activeRunCount,
|
||||
openFindingCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function gatherSessionInfo(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
registry: PluginRegistry,
|
||||
scm: SCM,
|
||||
projectConfig: ReturnType<typeof loadConfig>,
|
||||
reportsLimit: number = 0,
|
||||
|
|
@ -124,11 +160,17 @@ async function gatherSessionInfo(
|
|||
lastActivity = activityTs ? formatAge(activityTs) : "-";
|
||||
}
|
||||
|
||||
// Get agent's auto-generated summary via introspection
|
||||
// Get agent's auto-generated summary via introspection. The SessionManager
|
||||
// normalizes session.metadata.agent on read, so never infer the session agent
|
||||
// from current project/default config here.
|
||||
let claudeSummary: string | null = null;
|
||||
try {
|
||||
const introspection = await agent.getSessionInfo(session);
|
||||
claudeSummary = introspection?.summary ?? null;
|
||||
const agentName = session.metadata["agent"];
|
||||
if (agentName) {
|
||||
const agent = getAgentByNameFromRegistry(registry, agentName);
|
||||
const introspection = await agent.getSessionInfo(session);
|
||||
claudeSummary = introspection?.summary ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Summary extraction failed — not critical
|
||||
}
|
||||
|
|
@ -306,6 +348,44 @@ function printOrchestratorRow(info: SessionInfo): void {
|
|||
printReportRows(info.reports, " ");
|
||||
}
|
||||
|
||||
function printReviewStatus(summary: ProjectReviewStatus): void {
|
||||
if (summary.runCount === 0) return;
|
||||
|
||||
const findings =
|
||||
summary.openFindingCount === 1
|
||||
? "1 open finding"
|
||||
: `${summary.openFindingCount} open findings`;
|
||||
const active =
|
||||
summary.activeRunCount === 1 ? "1 active" : `${summary.activeRunCount} active`;
|
||||
|
||||
console.log(
|
||||
` ${chalk.magenta("Reviews:")} ${summary.runCount} run${summary.runCount !== 1 ? "s" : ""} · ${active} · ${findings}`,
|
||||
);
|
||||
|
||||
const visibleRuns = summary.runs
|
||||
.filter((run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0)
|
||||
.slice(0, 5);
|
||||
|
||||
for (const run of visibleRuns) {
|
||||
const findingText =
|
||||
run.openFindingCount === 1
|
||||
? "1 open finding"
|
||||
: `${run.openFindingCount} open findings`;
|
||||
console.log(
|
||||
` ${chalk.green(run.reviewerSessionId)} ${chalk.dim(run.status)} → ${chalk.cyan(run.linkedSessionId)} ${chalk.dim(findingText)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const hiddenCount = summary.runs.length - visibleRuns.length;
|
||||
if (hiddenCount > 0) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${hiddenCount} more review run${hiddenCount !== 1 ? "s" : ""}. Use \`ao review list ${summary.projectId}\`.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStatus(program: Command): void {
|
||||
program
|
||||
.command("status")
|
||||
|
|
@ -406,8 +486,12 @@ export function registerStatus(program: Command): void {
|
|||
// Show projects that have no sessions too (if not filtered)
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
const jsonOutput: SessionInfo[] = [];
|
||||
const reviewOutput: CodeReviewRunSummary[] = [];
|
||||
let totalWorkers = 0;
|
||||
let totalOrchestrators = 0;
|
||||
let totalReviewRuns = 0;
|
||||
let totalActiveReviewRuns = 0;
|
||||
let totalOpenReviewFindings = 0;
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const projectConfig = config.projects[projectId];
|
||||
|
|
@ -416,10 +500,15 @@ export function registerStatus(program: Command): void {
|
|||
const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const reviewStatus = gatherProjectReviewStatus(projectId);
|
||||
reviewOutput.push(...reviewStatus.runs);
|
||||
totalReviewRuns += reviewStatus.runCount;
|
||||
totalActiveReviewRuns += reviewStatus.activeRunCount;
|
||||
totalOpenReviewFindings += reviewStatus.openFindingCount;
|
||||
|
||||
// Resolve agent and SCM for this project via the shared registry
|
||||
const agentName = projectConfig.agent ?? config.defaults.agent;
|
||||
const agent = getAgentByNameFromRegistry(registry, agentName);
|
||||
// Resolve SCM for this project via the shared registry. Agents are
|
||||
// resolved per session because historical metadata may record a
|
||||
// different agent than the current project default.
|
||||
const scm = getSCMFromRegistry(registry, config, projectId);
|
||||
|
||||
if (!opts.json) {
|
||||
|
|
@ -429,13 +518,16 @@ export function registerStatus(program: Command): void {
|
|||
if (projectSessions.length === 0) {
|
||||
if (!opts.json) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
printReviewStatus(reviewStatus);
|
||||
console.log();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather all session info in parallel
|
||||
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config, reportsLimit));
|
||||
const infoPromises = projectSessions.map((s) =>
|
||||
gatherSessionInfo(s, registry, scm, config, reportsLimit),
|
||||
);
|
||||
const sessionInfos = await Promise.all(infoPromises);
|
||||
|
||||
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
|
||||
|
|
@ -462,6 +554,7 @@ export function registerStatus(program: Command): void {
|
|||
|
||||
if (workers.length === 0) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
printReviewStatus(reviewStatus);
|
||||
console.log();
|
||||
continue;
|
||||
}
|
||||
|
|
@ -470,13 +563,23 @@ export function registerStatus(program: Command): void {
|
|||
for (const info of workers) {
|
||||
printSessionRow(info);
|
||||
}
|
||||
printReviewStatus(reviewStatus);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ data: jsonOutput, meta: { hiddenTerminatedCount } },
|
||||
{
|
||||
data: jsonOutput,
|
||||
reviews: reviewOutput,
|
||||
meta: {
|
||||
hiddenTerminatedCount,
|
||||
reviewRunCount: totalReviewRuns,
|
||||
activeReviewRunCount: totalActiveReviewRuns,
|
||||
openReviewFindingCount: totalOpenReviewFindings,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
|
@ -487,6 +590,12 @@ export function registerStatus(program: Command): void {
|
|||
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
|
||||
(totalOrchestrators > 0
|
||||
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
|
||||
: "") +
|
||||
(totalReviewRuns > 0
|
||||
? ` · ${totalReviewRuns} review run${totalReviewRuns !== 1 ? "s" : ""}` +
|
||||
(totalOpenReviewFindings > 0
|
||||
? ` · ${totalOpenReviewFindings} open finding${totalOpenReviewFindings !== 1 ? "s" : ""}`
|
||||
: "")
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
isWindows,
|
||||
loadConfig,
|
||||
loadGlobalConfig,
|
||||
recordActivityEvent,
|
||||
type Session,
|
||||
} from "@aoagents/ao-core";
|
||||
import { runRepoScript } from "../lib/script-runner.js";
|
||||
|
|
@ -67,12 +68,16 @@ export function registerUpdate(program: Command): void {
|
|||
.option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)")
|
||||
.option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)")
|
||||
.option("--check", "Print version info as JSON without upgrading")
|
||||
.option("--no-restore", "Restart AO after updating but do not restore stopped sessions")
|
||||
.action(
|
||||
async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; check?: boolean }) => {
|
||||
async (opts: {
|
||||
skipSmoke?: boolean;
|
||||
smokeOnly?: boolean;
|
||||
check?: boolean;
|
||||
restore?: boolean;
|
||||
}) => {
|
||||
if (opts.skipSmoke && opts.smokeOnly) {
|
||||
console.error(
|
||||
"`ao update` does not allow `--skip-smoke` together with `--smoke-only`.",
|
||||
);
|
||||
console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +88,14 @@ export function registerUpdate(program: Command): void {
|
|||
|
||||
const method = detectInstallMethod();
|
||||
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_invoked",
|
||||
level: "info",
|
||||
summary: `ao update invoked (method: ${method})`,
|
||||
data: { method, options: opts },
|
||||
});
|
||||
|
||||
// Reject git-only flags up front when the install isn't a git source.
|
||||
// Without this, users copy/pasting `ao update --skip-smoke` from older
|
||||
// docs would silently no-op on npm/pnpm/bun installs (the flag would be
|
||||
|
|
@ -104,7 +117,7 @@ export function registerUpdate(program: Command): void {
|
|||
case "npm-global":
|
||||
case "pnpm-global":
|
||||
case "bun-global":
|
||||
await handleNpmUpdate(method);
|
||||
await handleNpmUpdate(method, { restore: opts.restore !== false });
|
||||
break;
|
||||
case "unknown":
|
||||
await handleUnknownUpdate();
|
||||
|
|
@ -124,21 +137,26 @@ async function handleCheck(): Promise<void> {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-session guard
|
||||
// Update lifecycle planning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refuse to update when the user has live sessions.
|
||||
*
|
||||
* Auto-stopping would lose the agent's in-flight context (and potentially
|
||||
* uncommitted work). The release doc is explicit: refuse, surface the
|
||||
* `ao stop` command, let the user decide.
|
||||
*
|
||||
* Best-effort — when no config is reachable (fresh install, broken yaml)
|
||||
* we skip the guard rather than blocking the update on a missing dependency.
|
||||
* Best-effort snapshot used by `ao update` to pause and resume AO around the
|
||||
* package-manager install. Missing/broken config should not block an update;
|
||||
* in that case we proceed without attempting a stop/start round trip.
|
||||
*/
|
||||
async function ensureNoActiveSessions(): Promise<boolean> {
|
||||
interface UpdateLifecyclePlan {
|
||||
runningBeforeUpdate: boolean;
|
||||
configPath?: string;
|
||||
primaryProjectId?: string;
|
||||
activeSessions: Session[];
|
||||
}
|
||||
|
||||
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
|
||||
let sessions: Session[];
|
||||
let configPath: string | undefined;
|
||||
let primaryProjectId: string | undefined;
|
||||
let runningBeforeUpdate = false;
|
||||
try {
|
||||
// Live signal first: running.json lists whichever projects the active
|
||||
// `ao start` daemon is currently polling. That can include local-only
|
||||
|
|
@ -151,6 +169,9 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
// truth for "which sessions does the running ao instance own?"
|
||||
const running = await getRunning();
|
||||
if (running && running.projects.length > 0) {
|
||||
runningBeforeUpdate = true;
|
||||
configPath = running.configPath;
|
||||
primaryProjectId = running.projects[0];
|
||||
// running.configPath could be local-wrapped (a project's
|
||||
// agent-orchestrator.yaml) OR the canonical global path. loadConfig
|
||||
// dispatches based on the path shape — both cases produce a full
|
||||
|
|
@ -165,15 +186,19 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
// SessionManager's enrichment will reconcile any stale-runtime
|
||||
// sessions to `killed`, so terminal statuses don't block the update.
|
||||
const globalPath = getGlobalConfigPath();
|
||||
if (!existsSync(globalPath)) return true;
|
||||
if (!existsSync(globalPath)) {
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
const globalConfig = loadGlobalConfig(globalPath);
|
||||
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
|
||||
return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
if (!isCanonicalGlobalConfigPath(globalPath)) {
|
||||
return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
configPath = globalPath;
|
||||
const config = loadConfig(globalPath);
|
||||
primaryProjectId = Object.keys(config.projects)[0];
|
||||
const sm = await getSessionManager(config);
|
||||
sessions = await sm.list();
|
||||
}
|
||||
|
|
@ -181,29 +206,132 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
// If we can't enumerate sessions, don't pretend there are zero — but
|
||||
// also don't block the upgrade indefinitely. Surface a soft warning.
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
"⚠ Could not check for active sessions before updating. Proceeding anyway.",
|
||||
),
|
||||
chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."),
|
||||
);
|
||||
return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
|
||||
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
|
||||
if (active.length === 0) return true;
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active };
|
||||
}
|
||||
|
||||
const noun = active.length === 1 ? "session" : "sessions";
|
||||
console.error(
|
||||
chalk.red(
|
||||
`\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`,
|
||||
),
|
||||
);
|
||||
for (const s of active.slice(0, 5)) {
|
||||
console.error(chalk.dim(` • ${s.id} (${s.status})`));
|
||||
async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
|
||||
const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0;
|
||||
if (!shouldStop) return false;
|
||||
|
||||
if (plan.activeSessions.length > 0) {
|
||||
const noun = plan.activeSessions.length === 1 ? "session" : "sessions";
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\n${plan.activeSessions.length} active ${noun} will be paused and restored after the update.`,
|
||||
),
|
||||
);
|
||||
for (const s of plan.activeSessions.slice(0, 5)) {
|
||||
console.log(chalk.dim(` • ${s.id} (${s.status})`));
|
||||
}
|
||||
if (plan.activeSessions.length > 5) {
|
||||
console.log(chalk.dim(` … and ${plan.activeSessions.length - 5} more`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim("\nAO is running; it will be restarted after the update."));
|
||||
}
|
||||
if (active.length > 5) {
|
||||
console.error(chalk.dim(` … and ${active.length - 5} more`));
|
||||
|
||||
const stopExit = await runAoLifecycleCommand(["stop", "--yes"], {
|
||||
configPath: plan.configPath,
|
||||
});
|
||||
if (stopExit !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update failed: internal ao stop exited non-zero`,
|
||||
data: { exitCode: stopExit },
|
||||
});
|
||||
console.error(chalk.red(`\nAO update could not stop the running daemon (exit ${stopExit}).`));
|
||||
process.exit(stopExit);
|
||||
}
|
||||
return false;
|
||||
|
||||
const afterStop = await getUpdateLifecyclePlan();
|
||||
if (afterStop.runningBeforeUpdate || afterStop.activeSessions.length > 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update failed: AO still appears active after internal ao stop`,
|
||||
data: {
|
||||
runningAfterStop: afterStop.runningBeforeUpdate,
|
||||
activeSessionCount: afterStop.activeSessions.length,
|
||||
activeSessionIds: afterStop.activeSessions.map((s) => s.id).slice(0, 20),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
"\nAO update stopped before installing because AO still appears to be running after `ao stop --yes`.",
|
||||
),
|
||||
);
|
||||
if (afterStop.activeSessions.length > 0) {
|
||||
console.error(chalk.dim("Still-active sessions:"));
|
||||
for (const s of afterStop.activeSessions.slice(0, 5)) {
|
||||
console.error(chalk.dim(` • ${s.id} (${s.status})`));
|
||||
}
|
||||
if (afterStop.activeSessions.length > 5) {
|
||||
console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`));
|
||||
}
|
||||
}
|
||||
console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return plan.runningBeforeUpdate;
|
||||
}
|
||||
|
||||
async function restartAoAfterUpdate(
|
||||
plan: UpdateLifecyclePlan,
|
||||
opts: { restore: boolean },
|
||||
): Promise<void> {
|
||||
const args = ["start"];
|
||||
if (plan.primaryProjectId) args.push(plan.primaryProjectId);
|
||||
args.push(opts.restore ? "--restore" : "--no-restore");
|
||||
|
||||
console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`));
|
||||
const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath });
|
||||
if (exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_restart_failed",
|
||||
level: "error",
|
||||
summary: `ao update could not restart AO after install`,
|
||||
data: { exitCode, args },
|
||||
});
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`\nAO was updated, but \`ao ${args.join(" ")}\` failed with exit ${exitCode}. ` +
|
||||
`Run it manually to restore your sessions.`,
|
||||
),
|
||||
);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
function runAoLifecycleCommand(
|
||||
args: string[],
|
||||
opts: { configPath?: string } = {},
|
||||
): Promise<number> {
|
||||
return new Promise<number>((resolveExit) => {
|
||||
const child = spawn("ao", args, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env,
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`));
|
||||
resolveExit(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
resolveExit(signal ? 1 : (code ?? 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -213,10 +341,10 @@ async function ensureNoActiveSessions(): Promise<boolean> {
|
|||
async function handleGitUpdate(opts: {
|
||||
skipSmoke?: boolean;
|
||||
smokeOnly?: boolean;
|
||||
restore?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!(await ensureNoActiveSessions())) {
|
||||
process.exit(1);
|
||||
}
|
||||
const lifecyclePlan = await getUpdateLifecyclePlan();
|
||||
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
|
||||
|
||||
const args: string[] = [];
|
||||
if (opts.skipSmoke) args.push("--skip-smoke");
|
||||
|
|
@ -225,14 +353,31 @@ async function handleGitUpdate(opts: {
|
|||
try {
|
||||
const exitCode = await runRepoScript("ao-update.sh", args);
|
||||
if (exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
|
||||
data: { method: "git", exitCode },
|
||||
});
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
invalidateCache();
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("Script not found: ao-update.sh")
|
||||
) {
|
||||
if (error instanceof Error && error.message.includes("Script not found: ao-update.sh")) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed: ao-update.sh missing from bundled assets`,
|
||||
data: { method: "git", reason: "script_missing" },
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
"ao-update.sh is missing from the bundled assets. " +
|
||||
|
|
@ -240,10 +385,26 @@ async function handleGitUpdate(opts: {
|
|||
"If you're on a package install, reinstall the package.",
|
||||
),
|
||||
);
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed`,
|
||||
data: {
|
||||
method: "git",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false });
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -252,7 +413,7 @@ async function handleGitUpdate(opts: {
|
|||
// npm / pnpm / bun global install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
||||
async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise<void> {
|
||||
const channel = resolveUpdateChannel();
|
||||
|
||||
// Snapshot the previously cached channel BEFORE we force a refresh, so we
|
||||
|
|
@ -260,9 +421,34 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
// would overwrite cache.channel before we can read it.
|
||||
const previousChannel = readCachedUpdateInfo(method)?.channel;
|
||||
|
||||
const info = await checkForUpdate({ force: true, channel });
|
||||
let info: Awaited<ReturnType<typeof checkForUpdate>>;
|
||||
try {
|
||||
info = await checkForUpdate({ force: true, channel });
|
||||
} catch (error) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: npm registry lookup threw`,
|
||||
data: {
|
||||
method,
|
||||
channel,
|
||||
reason: "registry_lookup_threw",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!info.latestVersion) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: npm registry lookup returned no version`,
|
||||
data: { method, channel, reason: "registry_unreachable" },
|
||||
});
|
||||
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -274,9 +460,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
// is the right UX for a channel transition, and the install command we'd
|
||||
// run is genuinely different even if the version-compare says "no".
|
||||
const isChannelSwitch =
|
||||
!info.isOutdated &&
|
||||
previousChannel !== undefined &&
|
||||
previousChannel !== channel;
|
||||
!info.isOutdated && previousChannel !== undefined && previousChannel !== channel;
|
||||
|
||||
// First-channel opt-in. previousChannel === undefined means we've never
|
||||
// installed via the auto-updater. A user who just ran `ao config set
|
||||
|
|
@ -305,9 +489,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
console.log(`Channel: ${chalk.cyan(channel)}`);
|
||||
if (isChannelSwitch) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`,
|
||||
),
|
||||
chalk.yellow(`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`),
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
|
|
@ -326,15 +508,12 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
const command = getUpdateCommand(method, channel);
|
||||
const apiInvoked = isApiInvoked();
|
||||
const interactive = isTTY() && !apiInvoked;
|
||||
const lifecyclePlan = await getUpdateLifecyclePlan();
|
||||
|
||||
// Non-interactive path: API-invoked OR piped output. We still gate on the
|
||||
// active-session guard (refusing returns true/false), but we never bail
|
||||
// out just because there's no terminal — the dashboard's "Update" click
|
||||
// must actually install. The only thing we skip is the confirm prompt.
|
||||
if (!(await ensureNoActiveSessions())) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Non-interactive path: API-invoked OR piped output. We still plan the
|
||||
// stop/start lifecycle, but we never bail out just because there's no
|
||||
// terminal — the dashboard's "Update" click must actually install. The
|
||||
// only thing we skip is the confirm prompt.
|
||||
if (interactive) {
|
||||
// Soft auto-install: when the user has opted into stable or nightly we
|
||||
// skip the confirm prompt — they've already said "keep me on this channel."
|
||||
|
|
@ -346,10 +525,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
isChannelSwitch || isFirstChannelOptIn
|
||||
? `Switch to ${channel} via ${chalk.cyan(command)}?`
|
||||
: `Run ${chalk.cyan(command)}?`;
|
||||
const confirmed = await promptConfirm(
|
||||
promptText,
|
||||
!(isChannelSwitch || isFirstChannelOptIn),
|
||||
);
|
||||
const confirmed = await promptConfirm(promptText, !(isChannelSwitch || isFirstChannelOptIn));
|
||||
if (!confirmed) return;
|
||||
} else {
|
||||
console.log(chalk.dim(`Updating: ${command}`));
|
||||
|
|
@ -364,18 +540,95 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const exitCode = await runNpmInstall(command);
|
||||
if (exitCode === 0) {
|
||||
invalidateCache();
|
||||
console.log(chalk.green("\nUpdate complete."));
|
||||
} else {
|
||||
process.exit(exitCode);
|
||||
const shouldRestart = await pauseAoForUpdate(lifecyclePlan);
|
||||
const installResult = await runNpmInstall(command);
|
||||
if (installResult.exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: install command exited non-zero`,
|
||||
data: {
|
||||
method,
|
||||
command,
|
||||
exitCode: installResult.exitCode,
|
||||
classification: classifyInstallFailure(installResult.output).kind,
|
||||
},
|
||||
});
|
||||
printInstallFailure({
|
||||
method,
|
||||
command,
|
||||
channel,
|
||||
currentVersion: info.currentVersion,
|
||||
exitCode: installResult.exitCode,
|
||||
output: installResult.output,
|
||||
});
|
||||
if (shouldRestart) {
|
||||
console.log(chalk.dim("\nRestarting AO with the existing installation..."));
|
||||
await restartAoAfterUpdate(lifecyclePlan, opts);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const verification = await verifyInstalledVersion(info.latestVersion, info.currentVersion);
|
||||
if (!verification.ok) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: installed version verification failed`,
|
||||
data: {
|
||||
method,
|
||||
command,
|
||||
expectedVersion: info.latestVersion,
|
||||
actualVersion: verification.actualVersion,
|
||||
output: verification.output,
|
||||
},
|
||||
});
|
||||
console.error(chalk.red(`\nAO was not verified after install.`));
|
||||
console.error(chalk.yellow(verification.message));
|
||||
console.error(chalk.dim(`Expected: ${info.latestVersion}`));
|
||||
console.error(chalk.dim(`Current before update: ${info.currentVersion}`));
|
||||
if (shouldRestart) {
|
||||
console.log(chalk.dim("\nRestarting AO before exiting..."));
|
||||
await restartAoAfterUpdate(lifecyclePlan, opts);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
invalidateCache();
|
||||
if (shouldRestart) {
|
||||
await restartAoAfterUpdate(lifecyclePlan, opts);
|
||||
}
|
||||
console.log(
|
||||
chalk.green(
|
||||
`\nUpdate complete: ${info.currentVersion} → ${verification.actualVersion}.` +
|
||||
(shouldRestart ? " AO restarted." : ""),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function runNpmInstall(command: string): Promise<number> {
|
||||
interface CommandResult {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
function runNpmInstall(command: string): Promise<CommandResult> {
|
||||
const [cmd, ...args] = command.split(" ");
|
||||
return new Promise<number>((resolveExit, reject) => {
|
||||
return runCommandCapture(cmd!, args, { echo: true }).then((result) => {
|
||||
if (result.exitCode !== 0) {
|
||||
console.error(chalk.yellow(`\n${cmd} exited with code ${result.exitCode}.`));
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function runCommandCapture(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { echo?: boolean } = {},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise<CommandResult>((resolveExit) => {
|
||||
// `shell: isWindows()` is required so PATHEXT gets consulted on Windows —
|
||||
// npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at
|
||||
// PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup
|
||||
|
|
@ -383,25 +636,162 @@ function runNpmInstall(command: string): Promise<number> {
|
|||
// keeps the shell window from flashing. Same fix that landed for the
|
||||
// dashboard's /api/update spawn in commit 9f29131d.
|
||||
const child = spawn(cmd!, args, {
|
||||
stdio: "inherit",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", reject);
|
||||
let output = "";
|
||||
const collect = (chunk: Buffer | string, stream: NodeJS.WriteStream): void => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
if (opts.echo) stream.write(chunk);
|
||||
};
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => collect(chunk, process.stdout));
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => collect(chunk, process.stderr));
|
||||
child.on("error", (error) => {
|
||||
output += `${error.name}: ${error.message}`;
|
||||
resolveExit({ exitCode: 1, output });
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolveExit(1);
|
||||
resolveExit({ exitCode: 1, output: `${output}\nTerminated by signal ${signal}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
console.error(chalk.yellow(`\n${cmd} exited with code ${code}.`));
|
||||
}
|
||||
resolveExit(code ?? 1);
|
||||
resolveExit({ exitCode: code ?? 1, output });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface VerificationResult {
|
||||
ok: boolean;
|
||||
actualVersion?: string;
|
||||
output: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function verifyInstalledVersion(
|
||||
expectedVersion: string,
|
||||
previousVersion: string,
|
||||
): Promise<VerificationResult> {
|
||||
const result = await runCommandCapture("ao", ["--version"]);
|
||||
const output = result.output.trim();
|
||||
const actualVersion = parseAoVersion(output);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
output,
|
||||
message: `\`ao --version\` failed with exit ${result.exitCode}.`,
|
||||
};
|
||||
}
|
||||
if (!actualVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
output,
|
||||
message: `Could not parse \`ao --version\` output: ${output || "<empty>"}`,
|
||||
};
|
||||
}
|
||||
if (actualVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
actualVersion,
|
||||
output,
|
||||
message:
|
||||
actualVersion === previousVersion
|
||||
? `The install command exited successfully, but AO is still on ${previousVersion}.`
|
||||
: `The install command exited successfully, but AO reports ${actualVersion}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, actualVersion, output, message: "verified" };
|
||||
}
|
||||
|
||||
function parseAoVersion(output: string): string | undefined {
|
||||
const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function classifyInstallFailure(output: string): { kind: string; guidance: string } {
|
||||
if (/ERR_PNPM_UNEXPECTED_VIRTUAL_STORE/i.test(output)) {
|
||||
return {
|
||||
kind: "pnpm_virtual_store",
|
||||
guidance:
|
||||
"pnpm's global store metadata is inconsistent. Try `pnpm store prune`, then retry `ao update`. " +
|
||||
"If pnpm remains stuck, use the npm fallback below.",
|
||||
};
|
||||
}
|
||||
if (/(?:EACCES|EPERM|permission denied|access denied)/i.test(output)) {
|
||||
return {
|
||||
kind: "permission",
|
||||
guidance:
|
||||
"The package manager could not write to the global install location. Fix your npm/pnpm global prefix permissions, or retry from a shell with access to that directory.",
|
||||
};
|
||||
}
|
||||
if (/(?:ENETUNREACH|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|socket hang up)/i.test(output)) {
|
||||
return {
|
||||
kind: "network",
|
||||
guidance:
|
||||
"The registry request failed due to a network error. Check connectivity/VPN/proxy settings and retry `ao update`.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
/(?:lockfile|ERR_PNPM_LOCKFILE|ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_BROKEN_LOCKFILE)/i.test(
|
||||
output,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "lockfile",
|
||||
guidance:
|
||||
"pnpm reported lockfile state problems. Clear the affected global install metadata or retry with the npm fallback below.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
/(?:registry|ERR_PNPM_FETCH|ERR_PNPM_META_FETCH_FAIL|E401|E403|E404|404 Not Found|401 Unauthorized|403 Forbidden)/i.test(
|
||||
output,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "registry",
|
||||
guidance:
|
||||
"The npm registry rejected or failed the package request. Check registry configuration, auth tokens, and the selected AO update channel.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "unknown",
|
||||
guidance:
|
||||
"The package manager failed before AO could verify the new version. Retry `ao update` after addressing the package-manager error below.",
|
||||
};
|
||||
}
|
||||
|
||||
function printInstallFailure(opts: {
|
||||
method: InstallMethod;
|
||||
command: string;
|
||||
channel: ReturnType<typeof resolveUpdateChannel>;
|
||||
currentVersion: string;
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}): void {
|
||||
const classification = classifyInstallFailure(opts.output);
|
||||
const fallbackCommand = getUpdateCommand("npm-global", opts.channel);
|
||||
|
||||
console.error(
|
||||
chalk.red(`\nAO was not updated. You are still on version ${opts.currentVersion}.`),
|
||||
);
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`The package manager (${opts.method.replace("-global", "")}) failed with exit ${opts.exitCode}.`,
|
||||
),
|
||||
);
|
||||
console.error(chalk.yellow(classification.guidance));
|
||||
console.error(chalk.dim(`\nTo retry: ao update`));
|
||||
if (opts.command !== fallbackCommand) {
|
||||
console.error(chalk.dim(`You can also try: ${fallbackCommand}`));
|
||||
}
|
||||
console.error(chalk.dim("\nPackage manager output:"));
|
||||
console.error(opts.output.trim() || "<no output>");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// homebrew install (notice only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -415,9 +805,7 @@ async function handleHomebrewUpdate(): Promise<void> {
|
|||
console.log(`Latest version: ${chalk.green(info.latestVersion)}`);
|
||||
}
|
||||
console.log();
|
||||
console.log(
|
||||
`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`,
|
||||
);
|
||||
console.log(`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
" (AO does not auto-install for brew installs because it would clobber brew's symlinks.)",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,12 +20,16 @@ terminalPort: 14800 # Optional terminal WebSocket port override
|
|||
directTerminalPort: 14801 # Optional direct terminal WebSocket port override
|
||||
readyThresholdMs: 300000 # Ms before "ready" becomes "idle" (default: 5 min)
|
||||
|
||||
observability:
|
||||
logLevel: warn # debug | info | warn | error
|
||||
stderr: false # Mirror structured logs to stderr
|
||||
|
||||
# ── Default plugins ─────────────────────────────────────────────────
|
||||
# These apply to all projects unless overridden per-project.
|
||||
|
||||
defaults:
|
||||
runtime: tmux # tmux | process
|
||||
agent: claude-code # claude-code | aider | codex | cursor | kimicode | opencode
|
||||
agent: claude-code # claude-code | aider | codex | cursor | kimicode | grok | opencode
|
||||
workspace: worktree # worktree | clone
|
||||
notifiers:
|
||||
- desktop # desktop | discord | slack | webhook | composio | openclaw
|
||||
|
|
@ -110,6 +114,12 @@ projects:
|
|||
notifiers:
|
||||
desktop:
|
||||
plugin: desktop
|
||||
# Run 'ao setup desktop' on macOS to use AO Notifier.app
|
||||
# backend: ao-app
|
||||
dashboard:
|
||||
plugin: dashboard
|
||||
# Run 'ao setup dashboard' to retain notifications in the web dashboard
|
||||
# limit: 50
|
||||
slack:
|
||||
plugin: slack
|
||||
# Requires SLACK_WEBHOOK_URL env var
|
||||
|
|
@ -119,8 +129,42 @@ notifiers:
|
|||
openclaw:
|
||||
plugin: openclaw
|
||||
# url: http://127.0.0.1:18789/hooks/agent
|
||||
# token: \${OPENCLAW_HOOKS_TOKEN}
|
||||
# openclawConfigPath: ~/.openclaw/openclaw.json
|
||||
# OpenClaw owns hooks.token in openclaw.json
|
||||
# Run 'ao setup openclaw' for guided configuration
|
||||
composio:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio' to connect Slack through Composio
|
||||
# userId: aoagent
|
||||
# connectedAccountId: ca_...
|
||||
# channelName: "#agents"
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
# toolVersion: "20260508_00" # optional Slack override
|
||||
composio-discord:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio-discord' for Discord webhook mode through Composio
|
||||
# defaultApp: discord
|
||||
# mode: webhook
|
||||
# webhookUrl: https://discord.com/api/webhooks/...
|
||||
# userId: aoagent
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
composio-discord-bot:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio-discord-bot' for Discord bot mode through Composio
|
||||
# defaultApp: discord
|
||||
# mode: bot
|
||||
# channelId: "1234567890"
|
||||
# userId: aoagent
|
||||
# connectedAccountId: ca_...
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
composio-mail:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio-mail' to connect Gmail through Composio
|
||||
# defaultApp: gmail
|
||||
# emailTo: alerts@example.com
|
||||
# userId: aoagent
|
||||
# connectedAccountId: ca_...
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
|
||||
# ── Notification routing (optional) ─────────────────────────────────
|
||||
# Route notifications by priority level.
|
||||
|
|
@ -138,12 +182,12 @@ notificationRouting:
|
|||
|
||||
# ── Available plugins ───────────────────────────────────────────────
|
||||
#
|
||||
# Agent: claude-code, aider, codex, cursor, kimicode, opencode
|
||||
# Agent: claude-code, aider, codex, cursor, kimicode, grok, opencode
|
||||
# Runtime: tmux, process
|
||||
# Workspace: worktree, clone
|
||||
# SCM: github, gitlab
|
||||
# Tracker: github, linear, gitlab
|
||||
# Notifier: desktop, discord, slack, webhook, composio, openclaw
|
||||
# Notifier: dashboard, desktop, discord, slack, webhook, composio, openclaw
|
||||
# Terminal: iterm2, web
|
||||
`.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
/** Keys that AO agents commonly need and OpenClaw may already store. */
|
||||
const RESOLVABLE_KEYS = [
|
||||
|
|
@ -66,8 +67,18 @@ function readOpenClawKeys(): Record<string, string> {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed config — silently skip.
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.credential_load_failed",
|
||||
level: "warn",
|
||||
summary: `failed to read or parse ~/.openclaw/openclaw.json`,
|
||||
data: {
|
||||
configPath,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
// Malformed config — silently skip (event surfaces it).
|
||||
}
|
||||
|
||||
return keys;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import {
|
||||
CONFIG_SCHEMA_URL,
|
||||
DEFAULT_DASHBOARD_NOTIFICATION_LIMIT,
|
||||
getDashboardNotificationStorePath,
|
||||
isCanonicalGlobalConfigPath,
|
||||
findConfigFile,
|
||||
normalizeDashboardNotificationLimit,
|
||||
readDashboardNotificationsFromFile,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
export interface DashboardSetupOptions {
|
||||
limit?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DashboardConfig {
|
||||
plugin: "dashboard";
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface ResolvedDashboardSetup {
|
||||
limit: number;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class DashboardSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DashboardSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new DashboardSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingDashboard(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["dashboard"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
|
||||
}
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value.trim(), 10)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new DashboardSetupError("Dashboard notification limit must be a number.");
|
||||
}
|
||||
return normalizeDashboardNotificationLimit(parsed);
|
||||
}
|
||||
|
||||
function resolveDashboardRoutingPreset(
|
||||
value: string | undefined,
|
||||
): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "dashboard") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new DashboardSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function toDashboardConfig(resolved: ResolvedDashboardSetup): DashboardConfig {
|
||||
return {
|
||||
plugin: "dashboard",
|
||||
limit: resolved.limit,
|
||||
};
|
||||
}
|
||||
|
||||
function writeDashboardConfig(configPath: string, resolved: ResolvedDashboardSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
notifiers["dashboard"] = toDashboardConfig(resolved);
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "dashboard", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Dashboard setup cancelled.");
|
||||
throw new DashboardSetupError("Dashboard setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingDashboard(
|
||||
plugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (plugin === undefined || plugin === "dashboard" || force) return true;
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new DashboardSetupError(
|
||||
`notifiers.dashboard already uses plugin "${String(plugin)}". Pass --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const answer = await clack.confirm({
|
||||
message: `notifiers.dashboard already uses plugin "${String(plugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(answer)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return answer === true;
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: DashboardSetupOptions,
|
||||
existingDashboard: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedDashboardSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const optionRoutingPreset = resolveDashboardRoutingPreset(opts.routingPreset);
|
||||
const existingLimit = parseLimit(existingDashboard["limit"]);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup dashboard ")));
|
||||
|
||||
while (true) {
|
||||
const limitInput = await clack.text({
|
||||
message: "How many dashboard notifications should AO keep?",
|
||||
placeholder: String(DEFAULT_DASHBOARD_NOTIFICATION_LIMIT),
|
||||
initialValue: stringValue(opts.limit) ?? String(existingLimit),
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseLimit(value);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(limitInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "dashboard", "dashboard", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return {
|
||||
limit: parseLimit(limitInput),
|
||||
routingPreset: routingSelection === "preserve" ? undefined : routingSelection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: DashboardSetupOptions,
|
||||
existingDashboard: Record<string, unknown>,
|
||||
): ResolvedDashboardSetup {
|
||||
const limit =
|
||||
opts.limit !== undefined
|
||||
? parseLimit(opts.limit)
|
||||
: opts.refresh
|
||||
? parseLimit(existingDashboard["limit"])
|
||||
: DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
|
||||
const routingPreset =
|
||||
resolveDashboardRoutingPreset(opts.routingPreset) ??
|
||||
(opts.refresh ? undefined : "urgent-action");
|
||||
|
||||
return { limit, routingPreset };
|
||||
}
|
||||
|
||||
function printStatus(): void {
|
||||
const context = readConfigContext();
|
||||
const existingDashboard = getExistingDashboard(context.rawConfig);
|
||||
const plugin = stringValue(existingDashboard["plugin"]);
|
||||
const limit = parseLimit(existingDashboard["limit"]);
|
||||
const storePath = getDashboardNotificationStorePath(context.configPath);
|
||||
const records = readDashboardNotificationsFromFile(storePath, limit);
|
||||
const latest = records.at(-1);
|
||||
|
||||
console.log(chalk.bold("Dashboard notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Limit: ${limit}`);
|
||||
console.log(` Store: ${storePath}`);
|
||||
console.log(` Stored: ${records.length}`);
|
||||
console.log(` Latest: ${latest?.receivedAt ?? chalk.dim("none")}`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "dashboard").label}`);
|
||||
}
|
||||
|
||||
export async function runDashboardSetupAction(opts: DashboardSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingDashboard = getExistingDashboard(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingDashboard(
|
||||
existingDashboard["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingDashboard)
|
||||
: await resolveInteractiveSetup(opts, existingDashboard, context.rawConfig);
|
||||
|
||||
writeDashboardConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Dashboard setup complete!")} AO will retain the latest ${resolved.limit} dashboard notifications.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to dashboard --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\nDashboard setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to dashboard --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Returns the user-facing base URL of the dashboard.
|
||||
*
|
||||
* When `AO_PUBLIC_URL` is set in the environment, AO is being fronted by a
|
||||
* reverse proxy (e.g. when running inside a remote dev container or behind
|
||||
* Caddy/nginx). All console output, `ao open` browser launches, and session
|
||||
* URLs surfaced to humans should use that public URL instead of localhost.
|
||||
*
|
||||
* The trailing slash is stripped for consistency so callers can append paths
|
||||
* without producing `//`.
|
||||
*
|
||||
* Internal IPC (the daemon hitting its own dashboard's API) is intentionally
|
||||
* **not** routed through this helper — those calls always use localhost since
|
||||
* they happen on the same host as the dashboard process.
|
||||
*
|
||||
* @param port - the local dashboard port; only used in the localhost fallback
|
||||
*/
|
||||
export function dashboardUrl(port: number): string {
|
||||
const publicUrl = process.env.AO_PUBLIC_URL?.trim();
|
||||
if (publicUrl) {
|
||||
return publicUrl.replace(/\/+$/, "");
|
||||
}
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const APP_NAME = "AO Notifier.app";
|
||||
const EXECUTABLE_NAME = "ao-notifier";
|
||||
const PLACEHOLDER_MARKER_NAME = "ao-notifier-placeholder";
|
||||
const DESKTOP_BACKENDS = ["auto", "ao-app", "terminal-notifier", "osascript"] as const;
|
||||
|
||||
type DesktopBackend = (typeof DESKTOP_BACKENDS)[number];
|
||||
|
||||
export class DesktopSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DesktopSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopSetupOptions {
|
||||
nonInteractive?: boolean;
|
||||
force?: boolean;
|
||||
status?: boolean;
|
||||
uninstall?: boolean;
|
||||
refresh?: boolean;
|
||||
backend?: string;
|
||||
dashboardUrl?: string;
|
||||
appPath?: string;
|
||||
test?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface JsonRecord {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DesktopConfigContext {
|
||||
configPath: string | undefined;
|
||||
rawConfig: Record<string, unknown>;
|
||||
existingDesktop: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedDesktopSetup {
|
||||
backend: DesktopBackend;
|
||||
dashboardUrl?: string;
|
||||
appPath: string;
|
||||
shouldWriteAppPath: boolean;
|
||||
shouldSendTest: boolean;
|
||||
refresh: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
function currentPlatform(): NodeJS.Platform | string {
|
||||
return process.env["AO_DESKTOP_SETUP_PLATFORM"] ?? platform();
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function isDesktopBackend(value: unknown): value is DesktopBackend {
|
||||
return typeof value === "string" && DESKTOP_BACKENDS.includes(value as DesktopBackend);
|
||||
}
|
||||
|
||||
function parseDesktopBackend(value: unknown): DesktopBackend | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (isDesktopBackend(value)) return value;
|
||||
throw new DesktopSetupError(
|
||||
`Invalid desktop backend "${String(value)}". Expected one of: ${DESKTOP_BACKENDS.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function packageDirFromImport(): string | null {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
return dirname(require.resolve("@aoagents/ao-notifier-macos/package.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getBundledNotifierAppPath(): string | null {
|
||||
const override = process.env["AO_NOTIFIER_MACOS_APP_PATH"];
|
||||
if (override) return override;
|
||||
|
||||
const packageDir = packageDirFromImport();
|
||||
if (packageDir) {
|
||||
return resolve(packageDir, "dist", APP_NAME);
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolve(here, "..", "..", "..", "notifier-macos", "dist", APP_NAME);
|
||||
}
|
||||
|
||||
export function getInstalledNotifierAppPath(): string {
|
||||
return process.env["AO_DESKTOP_APP_INSTALL_PATH"] ?? join(homedir(), "Applications", APP_NAME);
|
||||
}
|
||||
|
||||
export function getNotifierExecutablePath(appPath: string): string {
|
||||
return join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
|
||||
}
|
||||
|
||||
function getNotifierPlaceholderMarkerPath(appPath: string): string {
|
||||
return join(appPath, "Contents", "Resources", PLACEHOLDER_MARKER_NAME);
|
||||
}
|
||||
|
||||
function isPlaceholderNotifierApp(appPath: string): boolean {
|
||||
return existsSync(getNotifierPlaceholderMarkerPath(appPath));
|
||||
}
|
||||
|
||||
function isAppInstalled(appPath = getInstalledNotifierAppPath()): boolean {
|
||||
return existsSync(getNotifierExecutablePath(appPath)) && !isPlaceholderNotifierApp(appPath);
|
||||
}
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
try {
|
||||
execFileSync(command, ["--version"], { stdio: "ignore", windowsHide: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
function execNotifierJson(appPath: string, args: string[]): JsonRecord | null {
|
||||
try {
|
||||
const output = execFileSync(getNotifierExecutablePath(appPath), args, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return JSON.parse(output) as JsonRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: unknown): JsonRecord | null {
|
||||
try {
|
||||
const text = Buffer.isBuffer(output) ? output.toString("utf-8") : String(output ?? "");
|
||||
if (!text.trim()) return null;
|
||||
return JSON.parse(text) as JsonRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatExecError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function permissionDeniedMessage(): string {
|
||||
return (
|
||||
"macOS notification permission is denied for AO Notifier.app.\n" +
|
||||
" Open System Settings > Notifications > AO Notifier and enable Allow Notifications.\n" +
|
||||
" Then rerun: ao setup desktop --force"
|
||||
);
|
||||
}
|
||||
|
||||
function readConfigContext(): DesktopConfigContext {
|
||||
const configPath = findOptionalConfigPath();
|
||||
if (!configPath) {
|
||||
return {
|
||||
configPath,
|
||||
rawConfig: {},
|
||||
existingDesktop: {},
|
||||
};
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const rawConfig = (parseDocument(rawYaml).toJS() as Record<string, unknown>) ?? {};
|
||||
const notifiers = (rawConfig["notifiers"] as Record<string, unknown> | undefined) ?? {};
|
||||
const existingDesktop = (notifiers["desktop"] as Record<string, unknown> | undefined) ?? {};
|
||||
return {
|
||||
configPath,
|
||||
rawConfig,
|
||||
existingDesktop,
|
||||
};
|
||||
}
|
||||
|
||||
function printStatus(): void {
|
||||
const os = currentPlatform();
|
||||
const appPath = getInstalledNotifierAppPath();
|
||||
const installed = isAppInstalled(appPath);
|
||||
const version = installed ? execNotifierJson(appPath, ["--version-json"]) : null;
|
||||
const permission = installed ? execNotifierJson(appPath, ["--permission-status-json"]) : null;
|
||||
const context = readConfigContext();
|
||||
const configBackend = stringValue(context.existingDesktop["backend"]);
|
||||
const configDashboardUrl = stringValue(context.existingDesktop["dashboardUrl"]);
|
||||
const configAppPath = stringValue(context.existingDesktop["appPath"]);
|
||||
|
||||
console.log(chalk.bold("AO desktop notifier"));
|
||||
console.log(` platform: ${os}`);
|
||||
if (context.configPath) {
|
||||
console.log(` config: ${context.configPath}`);
|
||||
}
|
||||
console.log(` config backend: ${configBackend ?? "not configured"}`);
|
||||
console.log(` dashboardUrl: ${configDashboardUrl ?? "not configured"}`);
|
||||
console.log(` config appPath: ${configAppPath ?? "not configured"}`);
|
||||
console.log(` terminal-notifier: ${commandExists("terminal-notifier") ? "available" : "missing"}`);
|
||||
console.log(` osascript: ${commandExists("osascript") ? "available" : "missing"}`);
|
||||
console.log(` installed: ${installed ? "yes" : "no"}`);
|
||||
console.log(` app: ${appPath}`);
|
||||
console.log(` routing: ${getNotifierRoutingState(context.rawConfig, "desktop").label}`);
|
||||
if (version?.["version"]) {
|
||||
console.log(` version: ${String(version["version"])}`);
|
||||
}
|
||||
if (permission?.["status"]) {
|
||||
console.log(` permissions: ${String(permission["status"])}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyBundledApp(targetAppPath = getInstalledNotifierAppPath()): string {
|
||||
if (currentPlatform() !== "darwin") {
|
||||
throw new DesktopSetupError("ao setup desktop is currently only supported on macOS.");
|
||||
}
|
||||
|
||||
const sourceAppPath = getBundledNotifierAppPath();
|
||||
if (!sourceAppPath || !existsSync(getNotifierExecutablePath(sourceAppPath))) {
|
||||
throw new DesktopSetupError(
|
||||
"AO Notifier.app is not built. Run: pnpm --filter @aoagents/ao-notifier-macos build",
|
||||
);
|
||||
}
|
||||
|
||||
if (isPlaceholderNotifierApp(sourceAppPath)) {
|
||||
throw new DesktopSetupError(
|
||||
"AO Notifier.app was built as a non-macOS placeholder and cannot be installed. " +
|
||||
"Rebuild @aoagents/ao-notifier-macos on macOS, or use --backend terminal-notifier.",
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(targetAppPath), { recursive: true });
|
||||
rmSync(targetAppPath, { recursive: true, force: true });
|
||||
cpSync(sourceAppPath, targetAppPath, { recursive: true });
|
||||
|
||||
if (!isAppInstalled(targetAppPath)) {
|
||||
throw new DesktopSetupError(`AO Notifier.app install failed at ${targetAppPath}`);
|
||||
}
|
||||
|
||||
return targetAppPath;
|
||||
}
|
||||
|
||||
function requestPermission(appPath: string): void {
|
||||
let result: JsonRecord | null;
|
||||
|
||||
try {
|
||||
const output = execFileSync(getNotifierExecutablePath(appPath), ["--request-permission"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
result = parseJsonOutput(output);
|
||||
} catch (error) {
|
||||
const failure = error as { stdout?: unknown; stderr?: unknown };
|
||||
result = parseJsonOutput(failure.stdout);
|
||||
if (result?.["status"] === "denied") {
|
||||
throw new DesktopSetupError(permissionDeniedMessage());
|
||||
}
|
||||
|
||||
const stderr = Buffer.isBuffer(failure.stderr)
|
||||
? failure.stderr.toString("utf-8").trim()
|
||||
: String(failure.stderr ?? "").trim();
|
||||
throw new DesktopSetupError(
|
||||
`Could not request macOS notification permission: ${stderr || formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.["status"] === "denied") {
|
||||
throw new DesktopSetupError(permissionDeniedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function sendAoAppSetupNotification(appPath: string, dashboardUrl?: string): void {
|
||||
const payload = {
|
||||
title: "AO Notifier",
|
||||
body: "Desktop notifications are ready.",
|
||||
sound: false,
|
||||
defaultOpenUrl: dashboardUrl,
|
||||
event: {
|
||||
id: `desktop-setup-${Date.now()}`,
|
||||
type: "setup.desktop",
|
||||
priority: "info",
|
||||
sessionId: "setup",
|
||||
projectId: "ao",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
actions: dashboardUrl ? [{ label: "Open Dashboard", url: dashboardUrl }] : [],
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64");
|
||||
try {
|
||||
execFileSync(getNotifierExecutablePath(appPath), ["--notify-base64", encoded], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = error as { stderr?: unknown };
|
||||
const stderr = Buffer.isBuffer(failure.stderr)
|
||||
? failure.stderr.toString("utf-8").trim()
|
||||
: String(failure.stderr ?? "").trim();
|
||||
throw new DesktopSetupError(
|
||||
`Could not send desktop setup test notification: ${stderr || formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sendTerminalNotifierSetupNotification(dashboardUrl?: string): void {
|
||||
const args = ["-title", "AO Notifier", "-message", "Desktop notifications are ready."];
|
||||
if (dashboardUrl) args.push("-open", dashboardUrl);
|
||||
execFileSync("terminal-notifier", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function sendOsascriptSetupNotification(): void {
|
||||
execFileSync(
|
||||
"osascript",
|
||||
["-e", 'display notification "Desktop notifications are ready." with title "AO Notifier"'],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
}
|
||||
|
||||
function findOptionalConfigPath(): string | undefined {
|
||||
try {
|
||||
return findConfigFile() ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingDesktop(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "desktop" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new DesktopSetupError(
|
||||
`notifiers.desktop already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.desktop already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing desktop notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function dashboardUrlFromConfig(rawConfig: Record<string, unknown>): string | undefined {
|
||||
const port = rawConfig["port"];
|
||||
if (typeof port === "number") return `http://localhost:${port}`;
|
||||
if (typeof port === "string" && port.trim().length > 0) return `http://localhost:${port.trim()}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function chooseDesktopBackend(
|
||||
existingBackend: DesktopBackend | undefined,
|
||||
nonInteractive: boolean,
|
||||
): Promise<DesktopBackend> {
|
||||
if (nonInteractive) return existingBackend ?? "ao-app";
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const choice = await clack.select({
|
||||
message: "Choose the desktop notification backend:",
|
||||
options: [
|
||||
{
|
||||
value: "ao-app",
|
||||
label: existingBackend === "ao-app" ? "AO Notifier.app (current)" : "AO Notifier.app (recommended)",
|
||||
hint: "Native macOS app with actions and AO-specific behavior",
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: existingBackend === "auto" ? "Auto fallback (current)" : "Auto fallback",
|
||||
hint: "AO app if installed, then terminal-notifier, then osascript",
|
||||
},
|
||||
{
|
||||
value: "terminal-notifier",
|
||||
label: existingBackend === "terminal-notifier" ? "terminal-notifier (current)" : "terminal-notifier",
|
||||
hint: "Requires Homebrew package; supports click-to-open",
|
||||
},
|
||||
{
|
||||
value: "osascript",
|
||||
label: existingBackend === "osascript" ? "osascript (current)" : "osascript",
|
||||
hint: "Built into macOS; basic notifications only",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new DesktopSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
return choice as DesktopBackend;
|
||||
}
|
||||
|
||||
function resolveDashboardUrl(
|
||||
opts: DesktopSetupOptions,
|
||||
rawConfig: Record<string, unknown>,
|
||||
existingDesktop: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
return (
|
||||
stringValue(opts.dashboardUrl) ??
|
||||
dashboardUrlFromConfig(rawConfig) ??
|
||||
stringValue(existingDesktop["dashboardUrl"])
|
||||
);
|
||||
}
|
||||
|
||||
async function maybeInstallTerminalNotifier(nonInteractive: boolean): Promise<void> {
|
||||
if (commandExists("terminal-notifier")) return;
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new DesktopSetupError(
|
||||
"terminal-notifier is not installed. Install it with: brew install terminal-notifier",
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const install = await clack.confirm({
|
||||
message: "terminal-notifier is not installed. Install it with Homebrew now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (clack.isCancel(install) || !install) {
|
||||
throw new DesktopSetupError(
|
||||
"terminal-notifier is required for this backend. Install it with: brew install terminal-notifier",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync("brew", ["install", "terminal-notifier"], { stdio: "inherit" });
|
||||
} catch (error) {
|
||||
throw new DesktopSetupError(
|
||||
`Could not install terminal-notifier with Homebrew: ${formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOsascriptAvailable(): void {
|
||||
if (!commandExists("osascript")) {
|
||||
throw new DesktopSetupError("osascript is not available on this system.");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAutoBackend(appPath: string): DesktopBackend {
|
||||
if (currentPlatform() === "darwin" && isAppInstalled(appPath)) return "ao-app";
|
||||
if (commandExists("terminal-notifier")) return "terminal-notifier";
|
||||
if (commandExists("osascript")) return "osascript";
|
||||
throw new DesktopSetupError(
|
||||
"No desktop notification backend is available. Run `ao setup desktop --backend ao-app`, or install terminal-notifier.",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDesktopSetup(
|
||||
opts: DesktopSetupOptions,
|
||||
context: DesktopConfigContext,
|
||||
nonInteractive: boolean,
|
||||
): Promise<ResolvedDesktopSetup> {
|
||||
const explicitBackend = parseDesktopBackend(opts.backend);
|
||||
const existingBackend = parseDesktopBackend(context.existingDesktop["backend"]);
|
||||
const optionRoutingPreset = resolveDesktopRoutingPreset(opts.routingPreset);
|
||||
|
||||
while (true) {
|
||||
const backend =
|
||||
explicitBackend ??
|
||||
(await chooseDesktopBackend(opts.refresh ? existingBackend : undefined, nonInteractive));
|
||||
const appPath =
|
||||
stringValue(opts.appPath) ??
|
||||
stringValue(context.existingDesktop["appPath"]) ??
|
||||
getInstalledNotifierAppPath();
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(nonInteractive || !context.configPath
|
||||
? opts.refresh
|
||||
? undefined
|
||||
: "all"
|
||||
: await promptNotifierRoutingPreset(
|
||||
await import("@clack/prompts"),
|
||||
context.rawConfig,
|
||||
"desktop",
|
||||
"desktop",
|
||||
() => {
|
||||
throw new DesktopSetupError("Setup cancelled.", 0);
|
||||
},
|
||||
));
|
||||
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return {
|
||||
backend,
|
||||
dashboardUrl: resolveDashboardUrl(opts, context.rawConfig, context.existingDesktop),
|
||||
appPath,
|
||||
shouldWriteAppPath:
|
||||
Boolean(stringValue(opts.appPath)) ||
|
||||
stringValue(context.existingDesktop["appPath"]) !== undefined,
|
||||
shouldSendTest: opts.test !== false,
|
||||
refresh: Boolean(opts.refresh),
|
||||
routingPreset: routingSelection === "preserve" ? undefined : routingSelection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDesktopRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "desktop") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new DesktopSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareDesktopBackend(
|
||||
resolved: ResolvedDesktopSetup,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<DesktopBackend> {
|
||||
if (currentPlatform() !== "darwin") {
|
||||
throw new DesktopSetupError("ao setup desktop is currently only supported on macOS.");
|
||||
}
|
||||
|
||||
if (resolved.backend === "ao-app") {
|
||||
const shouldInstall = !resolved.refresh || force || !isAppInstalled(resolved.appPath);
|
||||
if (shouldInstall) {
|
||||
const appPath = copyBundledApp(resolved.appPath);
|
||||
console.log(chalk.green(`✓ Installed ${APP_NAME} to ${appPath}`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ ${APP_NAME} is installed at ${resolved.appPath}`));
|
||||
}
|
||||
requestPermission(resolved.appPath);
|
||||
console.log(chalk.green("✓ Notification permission checked"));
|
||||
return "ao-app";
|
||||
}
|
||||
|
||||
if (resolved.backend === "terminal-notifier") {
|
||||
await maybeInstallTerminalNotifier(nonInteractive);
|
||||
console.log(chalk.green("✓ terminal-notifier is available"));
|
||||
return "terminal-notifier";
|
||||
}
|
||||
|
||||
if (resolved.backend === "osascript") {
|
||||
assertOsascriptAvailable();
|
||||
console.log(chalk.green("✓ osascript is available"));
|
||||
return "osascript";
|
||||
}
|
||||
|
||||
const effectiveBackend = resolveAutoBackend(resolved.appPath);
|
||||
if (effectiveBackend === "ao-app") {
|
||||
requestPermission(resolved.appPath);
|
||||
console.log(chalk.green(`✓ auto backend resolved to ${APP_NAME}`));
|
||||
} else if (effectiveBackend === "terminal-notifier") {
|
||||
console.log(chalk.green("✓ auto backend resolved to terminal-notifier"));
|
||||
} else {
|
||||
console.log(chalk.green("✓ auto backend resolved to osascript"));
|
||||
}
|
||||
return effectiveBackend;
|
||||
}
|
||||
|
||||
function sendBackendSetupNotification(
|
||||
effectiveBackend: DesktopBackend,
|
||||
resolved: ResolvedDesktopSetup,
|
||||
): void {
|
||||
try {
|
||||
if (effectiveBackend === "ao-app") {
|
||||
sendAoAppSetupNotification(resolved.appPath, resolved.dashboardUrl);
|
||||
} else if (effectiveBackend === "terminal-notifier") {
|
||||
sendTerminalNotifierSetupNotification(resolved.dashboardUrl);
|
||||
} else if (effectiveBackend === "osascript") {
|
||||
sendOsascriptSetupNotification();
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DesktopSetupError(
|
||||
`Could not send desktop setup test notification: ${formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function wireDesktopConfig(
|
||||
configPath: string | undefined,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
resolved: ResolvedDesktopSetup,
|
||||
conflictAlreadyChecked = false,
|
||||
): Promise<boolean> {
|
||||
if (!configPath) {
|
||||
console.log(chalk.dim("No agent-orchestrator.yaml found; skipping config wiring."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
const notifiers = (rawConfig["notifiers"] as Record<string, unknown> | undefined) ?? {};
|
||||
const existingDesktop = (notifiers["desktop"] as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
if (
|
||||
!conflictAlreadyChecked &&
|
||||
!(await shouldReplaceConflictingDesktop(
|
||||
existingDesktop["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desktopConfig: Record<string, unknown> = {
|
||||
...existingDesktop,
|
||||
plugin: "desktop",
|
||||
backend: resolved.backend,
|
||||
};
|
||||
if (resolved.dashboardUrl) desktopConfig["dashboardUrl"] = resolved.dashboardUrl;
|
||||
if (resolved.shouldWriteAppPath) desktopConfig["appPath"] = resolved.appPath;
|
||||
|
||||
notifiers["desktop"] = desktopConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = (rawConfig["defaults"] as Record<string, unknown> | undefined) ?? {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "desktop", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
console.log(chalk.green(`✓ Config written to ${configPath}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function canWireDesktopConfig(
|
||||
configPath: string | undefined,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!configPath) return false;
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
const notifiers = (rawConfig["notifiers"] as Record<string, unknown> | undefined) ?? {};
|
||||
const existingDesktop = (notifiers["desktop"] as Record<string, unknown> | undefined) ?? {};
|
||||
return shouldReplaceConflictingDesktop(existingDesktop["plugin"], force, nonInteractive);
|
||||
}
|
||||
|
||||
function uninstallDesktopApp(): void {
|
||||
const appPath = getInstalledNotifierAppPath();
|
||||
rmSync(appPath, { recursive: true, force: true });
|
||||
console.log(chalk.green(`✓ Removed ${appPath}`));
|
||||
console.log(chalk.dim("AO config was not changed."));
|
||||
}
|
||||
|
||||
export async function runDesktopSetupAction(opts: DesktopSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.uninstall) {
|
||||
uninstallDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const shouldWireConfig = await canWireDesktopConfig(context.configPath, force, nonInteractive);
|
||||
if (context.configPath && !shouldWireConfig) {
|
||||
console.log(chalk.dim("Skipped config wiring."));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await resolveDesktopSetup(opts, context, nonInteractive);
|
||||
const effectiveBackend = await prepareDesktopBackend(resolved, force, nonInteractive);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
sendBackendSetupNotification(effectiveBackend, resolved);
|
||||
console.log(chalk.green("✓ Sent desktop setup test notification"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped desktop setup test notification."));
|
||||
}
|
||||
|
||||
if (shouldWireConfig) {
|
||||
await wireDesktopConfig(context.configPath, force, nonInteractive, resolved, true);
|
||||
} else if (!context.configPath) {
|
||||
console.log(chalk.dim("No agent-orchestrator.yaml found; skipping config wiring."));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped config wiring."));
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Desktop setup complete!")} AO will use ${resolved.backend} for desktop notifications.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to desktop --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Desktop setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to desktop --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ const AGENT_PLUGINS: Array<{ name: string; pkg: string }> = [
|
|||
{ name: "codex", pkg: "@aoagents/ao-plugin-agent-codex" },
|
||||
{ name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" },
|
||||
{ name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" },
|
||||
{ name: "grok", pkg: "@aoagents/ao-plugin-agent-grok" },
|
||||
{ name: "opencode", pkg: "@aoagents/ao-plugin-agent-opencode" },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,753 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const DISCORD_APP_URL = "https://discord.com/app";
|
||||
const DISCORD_SUPPORT_WEBHOOK_URL =
|
||||
"https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks";
|
||||
const DISCORD_WEBHOOK_DOCS_URL = "https://docs.discord.com/developers/resources/webhook";
|
||||
const DEFAULT_USERNAME = "Agent Orchestrator";
|
||||
const DEFAULT_RETRIES = 2;
|
||||
const DEFAULT_RETRY_DELAY_MS = 1000;
|
||||
const SETUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface DiscordSetupOptions {
|
||||
webhookUrl?: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
threadId?: string;
|
||||
retries?: string;
|
||||
retryDelayMs?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedDiscordSetup {
|
||||
webhookUrl: string;
|
||||
username: string;
|
||||
avatarUrl?: string;
|
||||
threadId?: string;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
shouldSendTest: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class DiscordSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DiscordSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function validateDiscordWebhookUrl(webhookUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(webhookUrl);
|
||||
} catch {
|
||||
throw new DiscordSetupError("Discord webhook URL is invalid.");
|
||||
}
|
||||
|
||||
const validHost = parsed.hostname === "discord.com" || parsed.hostname === "discordapp.com";
|
||||
if (parsed.protocol !== "https:" || !validHost || !parsed.pathname.startsWith("/api/webhooks/")) {
|
||||
throw new DiscordSetupError(
|
||||
"Discord webhook URL must look like https://discord.com/api/webhooks/...",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalHttpUrl(value: string | undefined, label: string): void {
|
||||
if (!value) return;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new DiscordSetupError(`${label} is invalid.`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new DiscordSetupError(`${label} must start with http:// or https://.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: unknown, label: string, fallback: number): number {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new DiscordSetupError(`${label} must be a non-negative integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function existingIntegerText(value: unknown, fallback: number): string {
|
||||
if (value === undefined || value === null || value === "") return String(fallback);
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? String(parsed) : String(fallback);
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new DiscordSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingDiscord(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["discord"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function effectiveWebhookUrl(webhookUrl: string, threadId?: string): string {
|
||||
if (!threadId) return webhookUrl;
|
||||
const separator = webhookUrl.includes("?") ? "&" : "?";
|
||||
return `${webhookUrl}${separator}thread_id=${encodeURIComponent(threadId)}`;
|
||||
}
|
||||
|
||||
function buildSetupPayload(resolved: ResolvedDiscordSetup): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: resolved.username,
|
||||
content: "AO Discord notifications are ready.",
|
||||
embeds: [
|
||||
{
|
||||
title: "AO Discord notifications are ready",
|
||||
description: "This channel is now configured to receive AO notifications.",
|
||||
color: 0x57f287,
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: { text: "Agent Orchestrator" },
|
||||
},
|
||||
],
|
||||
};
|
||||
if (resolved.avatarUrl) payload["avatar_url"] = resolved.avatarUrl;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function sendSetupProbe(resolved: ResolvedDiscordSetup): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
|
||||
const url = effectiveWebhookUrl(resolved.webhookUrl, resolved.threadId);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildSetupPayload(resolved)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 204) return;
|
||||
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new DiscordSetupError(
|
||||
`Discord setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DiscordSetupError) throw error;
|
||||
throw new DiscordSetupError(`Discord setup test failed: ${formatFetchError(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingDiscord(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "discord" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new DiscordSetupError(
|
||||
`notifiers.discord already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.discord already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing Discord notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function printManualWebhookInstructions(): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("Create a Discord incoming webhook"));
|
||||
console.log(` 1. Open ${DISCORD_APP_URL}`);
|
||||
console.log(" 2. Open the target server and channel.");
|
||||
console.log(" 3. Open Edit Channel > Integrations > Webhooks.");
|
||||
console.log(" 4. Create a new webhook.");
|
||||
console.log(" 5. Copy the webhook URL and paste it here.");
|
||||
console.log(chalk.dim(`Discord help: ${DISCORD_SUPPORT_WEBHOOK_URL}`));
|
||||
console.log(chalk.dim(`Developer docs: ${DISCORD_WEBHOOK_DOCS_URL}`));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function explainChannelBinding(): void {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"Discord webhook URLs are bound to the channel selected in Discord. To change channels, create a webhook in that channel.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new DiscordSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function promptDiscordWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const webhookUrlInput = await clack.text({
|
||||
message: "Discord webhook URL:",
|
||||
placeholder: "https://discord.com/api/webhooks/...",
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "Discord webhook URL is required";
|
||||
try {
|
||||
validateDiscordWebhookUrl(String(value));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(webhookUrlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return String(webhookUrlInput);
|
||||
}
|
||||
|
||||
async function promptAfterWebhookInstructions(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
printManualWebhookInstructions();
|
||||
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "After creating the Discord webhook, what do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste webhook URL",
|
||||
hint: "Continue setup",
|
||||
},
|
||||
{
|
||||
value: "show-steps",
|
||||
label: "Show steps again",
|
||||
hint: "Reprint the Discord links and steps",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "show-steps") {
|
||||
printManualWebhookInstructions();
|
||||
continue;
|
||||
}
|
||||
if (next === "enter-url") {
|
||||
return promptDiscordWebhookUrl(clack, initialValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptChangeWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "How do you want to change the Discord webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste new webhook URL",
|
||||
hint: "Use a different Discord webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print Discord steps and wait",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "enter-url") {
|
||||
return promptDiscordWebhookUrl(clack, existingWebhookUrl);
|
||||
}
|
||||
if (next === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: DiscordSetupOptions,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedWebhookUrl = stringValue(opts.webhookUrl);
|
||||
if (providedWebhookUrl) return providedWebhookUrl;
|
||||
|
||||
while (true) {
|
||||
const source = existingWebhookUrl
|
||||
? await clack.select({
|
||||
message: "Discord notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing webhook URL",
|
||||
hint: "Keep sending to the currently configured Discord channel",
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Change webhook URL",
|
||||
hint: "Paste a different Discord webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print Discord steps and wait",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
})
|
||||
: await clack.select({
|
||||
message: "Do you already have a Discord webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "have-url",
|
||||
label: "Yes, I have the URL",
|
||||
hint: "Paste the existing Discord webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "No, show me how to create one",
|
||||
hint: "AO will print Discord steps and wait",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing" && existingWebhookUrl) {
|
||||
return existingWebhookUrl;
|
||||
}
|
||||
|
||||
if (source === "change-url") {
|
||||
const result = await promptChangeWebhookUrl(clack, existingWebhookUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (source === "have-url") {
|
||||
return promptDiscordWebhookUrl(clack, undefined);
|
||||
}
|
||||
|
||||
if (source === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: DiscordSetupOptions,
|
||||
existingDiscord: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedDiscordSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingWebhookUrl = stringValue(existingDiscord["webhookUrl"]);
|
||||
const optionRoutingPreset = resolveDiscordRoutingPreset(opts.routingPreset);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup discord ")));
|
||||
explainChannelBinding();
|
||||
|
||||
while (true) {
|
||||
const resolvedWebhookUrl = await resolveInteractiveWebhookUrl(clack, opts, existingWebhookUrl);
|
||||
|
||||
const usernameInput = await clack.text({
|
||||
message: "Display name AO should request for Discord messages:",
|
||||
placeholder: DEFAULT_USERNAME,
|
||||
initialValue:
|
||||
stringValue(opts.username) ?? stringValue(existingDiscord["username"]) ?? DEFAULT_USERNAME,
|
||||
});
|
||||
|
||||
if (clack.isCancel(usernameInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const avatarUrlInput = await clack.text({
|
||||
message: "Avatar image URL (optional):",
|
||||
placeholder: "https://example.com/avatar.png",
|
||||
initialValue: stringValue(opts.avatarUrl) ?? stringValue(existingDiscord["avatarUrl"]),
|
||||
validate: (value) => {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
validateOptionalHttpUrl(String(value), "Avatar URL");
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(avatarUrlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const threadIdInput = await clack.text({
|
||||
message: "Thread ID (optional; posts into an existing Discord thread):",
|
||||
placeholder: "1234567890",
|
||||
initialValue: stringValue(opts.threadId) ?? stringValue(existingDiscord["threadId"]),
|
||||
});
|
||||
|
||||
if (clack.isCancel(threadIdInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const retriesInput = await clack.text({
|
||||
message: "Retries for rate limits, network errors, and 5xx responses:",
|
||||
placeholder: String(DEFAULT_RETRIES),
|
||||
initialValue:
|
||||
stringValue(opts.retries) ??
|
||||
existingIntegerText(existingDiscord["retries"], DEFAULT_RETRIES),
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseNonNegativeInteger(value, "Retries", DEFAULT_RETRIES);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(retriesInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const retryDelayInput = await clack.text({
|
||||
message: "Base retry delay in milliseconds:",
|
||||
placeholder: String(DEFAULT_RETRY_DELAY_MS),
|
||||
initialValue:
|
||||
stringValue(opts.retryDelayMs) ??
|
||||
existingIntegerText(existingDiscord["retryDelayMs"], DEFAULT_RETRY_DELAY_MS),
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseNonNegativeInteger(value, "Retry delay", DEFAULT_RETRY_DELAY_MS);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(retryDelayInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "discord", "Discord", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return buildResolvedSetup(
|
||||
String(resolvedWebhookUrl),
|
||||
stringValue(usernameInput),
|
||||
stringValue(avatarUrlInput),
|
||||
stringValue(threadIdInput),
|
||||
retriesInput,
|
||||
retryDelayInput,
|
||||
routingSelection === "preserve" ? undefined : routingSelection,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: DiscordSetupOptions,
|
||||
existingDiscord: Record<string, unknown>,
|
||||
): ResolvedDiscordSetup {
|
||||
const webhookUrl =
|
||||
stringValue(opts.webhookUrl) ??
|
||||
(opts.refresh ? stringValue(existingDiscord["webhookUrl"]) : undefined);
|
||||
if (!webhookUrl) {
|
||||
throw new DiscordSetupError(
|
||||
"Discord webhook URL is required. Pass --webhook-url, or run `ao setup discord --refresh` with an existing Discord config.",
|
||||
);
|
||||
}
|
||||
|
||||
return buildResolvedSetup(
|
||||
webhookUrl,
|
||||
stringValue(opts.username) ?? stringValue(existingDiscord["username"]),
|
||||
stringValue(opts.avatarUrl) ?? stringValue(existingDiscord["avatarUrl"]),
|
||||
stringValue(opts.threadId) ?? stringValue(existingDiscord["threadId"]),
|
||||
opts.retries ?? existingDiscord["retries"],
|
||||
opts.retryDelayMs ?? existingDiscord["retryDelayMs"],
|
||||
resolveDiscordRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function buildResolvedSetup(
|
||||
webhookUrl: string,
|
||||
username: string | undefined,
|
||||
avatarUrl: string | undefined,
|
||||
threadId: string | undefined,
|
||||
retriesValue: unknown,
|
||||
retryDelayMsValue: unknown,
|
||||
routingPreset: NotifierRoutingPreset | undefined,
|
||||
opts: DiscordSetupOptions,
|
||||
): ResolvedDiscordSetup {
|
||||
const normalizedWebhookUrl = webhookUrl.trim();
|
||||
validateDiscordWebhookUrl(normalizedWebhookUrl);
|
||||
validateOptionalHttpUrl(avatarUrl, "Avatar URL");
|
||||
|
||||
return {
|
||||
webhookUrl: normalizedWebhookUrl,
|
||||
username: username ?? DEFAULT_USERNAME,
|
||||
avatarUrl,
|
||||
threadId,
|
||||
retries: parseNonNegativeInteger(retriesValue, "Retries", DEFAULT_RETRIES),
|
||||
retryDelayMs: parseNonNegativeInteger(retryDelayMsValue, "Retry delay", DEFAULT_RETRY_DELAY_MS),
|
||||
shouldSendTest: opts.test !== false,
|
||||
routingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscordRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "Discord") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new DiscordSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiscordConfig(configPath: string, resolved: ResolvedDiscordSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existingDiscord = isRecord(notifiers["discord"]) ? notifiers["discord"] : {};
|
||||
const discordConfig: Record<string, unknown> = {
|
||||
...existingDiscord,
|
||||
plugin: "discord",
|
||||
webhookUrl: resolved.webhookUrl,
|
||||
username: resolved.username,
|
||||
retries: resolved.retries,
|
||||
retryDelayMs: resolved.retryDelayMs,
|
||||
};
|
||||
if (resolved.avatarUrl) discordConfig["avatarUrl"] = resolved.avatarUrl;
|
||||
else delete discordConfig["avatarUrl"];
|
||||
if (resolved.threadId) discordConfig["threadId"] = resolved.threadId;
|
||||
else delete discordConfig["threadId"];
|
||||
notifiers["discord"] = discordConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "discord", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
function webhookUrlStatus(webhookUrl: string | undefined): string {
|
||||
if (!webhookUrl) return "not configured";
|
||||
try {
|
||||
return `configured (${new URL(webhookUrl).hostname})`;
|
||||
} catch {
|
||||
return "configured";
|
||||
}
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingDiscord = getExistingDiscord(context.rawConfig);
|
||||
const plugin = stringValue(existingDiscord["plugin"]);
|
||||
const webhookUrl = stringValue(existingDiscord["webhookUrl"]);
|
||||
const username = stringValue(existingDiscord["username"]) ?? DEFAULT_USERNAME;
|
||||
const avatarUrl = stringValue(existingDiscord["avatarUrl"]);
|
||||
const threadId = stringValue(existingDiscord["threadId"]);
|
||||
const retries = parseNonNegativeInteger(existingDiscord["retries"], "Retries", DEFAULT_RETRIES);
|
||||
const retryDelayMs = parseNonNegativeInteger(
|
||||
existingDiscord["retryDelayMs"],
|
||||
"Retry delay",
|
||||
DEFAULT_RETRY_DELAY_MS,
|
||||
);
|
||||
|
||||
console.log(chalk.bold("Discord notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Webhook URL: ${webhookUrlStatus(webhookUrl)}`);
|
||||
console.log(` Username: ${username}`);
|
||||
console.log(` Avatar URL: ${avatarUrl ?? chalk.dim("not configured")}`);
|
||||
console.log(` Thread ID: ${threadId ?? chalk.dim("not configured")}`);
|
||||
console.log(` Retries: ${retries}`);
|
||||
console.log(` Retry delay: ${retryDelayMs}ms`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "discord").label}`);
|
||||
|
||||
if (plugin !== "discord" || !webhookUrl) return;
|
||||
|
||||
try {
|
||||
await sendSetupProbe(
|
||||
buildResolvedSetup(webhookUrl, username, avatarUrl, threadId, retries, retryDelayMs, undefined, {
|
||||
test: true,
|
||||
}),
|
||||
);
|
||||
console.log(chalk.green(" Probe: PASS"));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDiscordSetupAction(opts: DiscordSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingDiscord = getExistingDiscord(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingDiscord(
|
||||
existingDiscord["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingDiscord)
|
||||
: await resolveInteractiveSetup(opts, existingDiscord, context.rawConfig);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
await sendSetupProbe(resolved);
|
||||
console.log(chalk.green("✓ Discord setup test passed"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped Discord setup test."));
|
||||
}
|
||||
|
||||
writeDiscordConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Discord setup complete!")} AO will send notifications through the configured Discord webhook.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to discord --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Discord setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to discord --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import type {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
outro,
|
||||
password,
|
||||
select,
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
|
||||
export const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
export type NotificationPriority = (typeof NOTIFICATION_PRIORITIES)[number];
|
||||
export type NotifierRoutingPreset = "urgent-only" | "urgent-action" | "all";
|
||||
export type NotifierRoutingSelection = NotifierRoutingPreset | "preserve" | "back";
|
||||
|
||||
export interface ClackPrompts {
|
||||
cancel: typeof cancel;
|
||||
confirm: typeof confirm;
|
||||
intro: typeof intro;
|
||||
isCancel: typeof isCancel;
|
||||
log: typeof log;
|
||||
outro: typeof outro;
|
||||
password: typeof password;
|
||||
select: typeof select;
|
||||
spinner: typeof spinner;
|
||||
text: typeof text;
|
||||
}
|
||||
|
||||
const ROUTING_PRESET_PRIORITIES: Record<NotifierRoutingPreset, readonly NotificationPriority[]> = {
|
||||
"urgent-only": ["urgent"],
|
||||
"urgent-action": ["urgent", "action"],
|
||||
all: ["urgent", "action", "warning", "info"],
|
||||
};
|
||||
|
||||
export interface NotifierRoutingState {
|
||||
preset?: NotifierRoutingPreset;
|
||||
priorities: NotificationPriority[];
|
||||
hasRouting: boolean;
|
||||
isCustom: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
if (typeof value === "string") return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
export function isNotifierRoutingPreset(value: string | undefined): value is NotifierRoutingPreset {
|
||||
return value === "urgent-only" || value === "urgent-action" || value === "all";
|
||||
}
|
||||
|
||||
export function parseNotifierRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
return isNotifierRoutingPreset(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function notifierRoutingPresetValues(): string {
|
||||
return "urgent-only | urgent-action | all";
|
||||
}
|
||||
|
||||
export function routingLabel(preset: NotifierRoutingPreset): string {
|
||||
if (preset === "all") return "All priorities";
|
||||
if (preset === "urgent-only") return "Urgent only";
|
||||
return "Urgent + action";
|
||||
}
|
||||
|
||||
export function getNotifierRoutingState(
|
||||
rawConfig: Record<string, unknown>,
|
||||
notifierName: string,
|
||||
): NotifierRoutingState {
|
||||
const routing = isRecord(rawConfig["notificationRouting"])
|
||||
? rawConfig["notificationRouting"]
|
||||
: {};
|
||||
const priorities = NOTIFICATION_PRIORITIES.filter((priority) =>
|
||||
asStringArray(routing[priority]).includes(notifierName),
|
||||
);
|
||||
const hasRouting = priorities.length > 0;
|
||||
|
||||
for (const [preset, presetPriorities] of Object.entries(ROUTING_PRESET_PRIORITIES) as Array<
|
||||
[NotifierRoutingPreset, readonly NotificationPriority[]]
|
||||
>) {
|
||||
if (
|
||||
priorities.length === presetPriorities.length &&
|
||||
presetPriorities.every((priority) => priorities.includes(priority))
|
||||
) {
|
||||
return {
|
||||
preset,
|
||||
priorities,
|
||||
hasRouting,
|
||||
isCustom: false,
|
||||
label: routingLabel(preset),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
priorities,
|
||||
hasRouting,
|
||||
isCustom: hasRouting,
|
||||
label: hasRouting ? priorities.join(" + ") : "not routed",
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureNotifierDefault(rawConfig: Record<string, unknown>, notifierName: string): void {
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
defaults["notifiers"] = unique([
|
||||
...asStringArray(defaults["notifiers"]).filter((value) => value !== notifierName),
|
||||
notifierName,
|
||||
]);
|
||||
rawConfig["defaults"] = defaults;
|
||||
}
|
||||
|
||||
export function applyNotifierRoutingPreset(
|
||||
rawConfig: Record<string, unknown>,
|
||||
notifierName: string,
|
||||
preset: NotifierRoutingPreset | undefined,
|
||||
): void {
|
||||
if (!preset) return;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
const defaultNotifiers = asStringArray(defaults["notifiers"]);
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
const notificationRouting = isRecord(rawConfig["notificationRouting"])
|
||||
? rawConfig["notificationRouting"]
|
||||
: {};
|
||||
const selectedPriorities = new Set<NotificationPriority>(ROUTING_PRESET_PRIORITIES[preset]);
|
||||
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
const base = hasOwn(notificationRouting, priority)
|
||||
? asStringArray(notificationRouting[priority])
|
||||
: defaultNotifiers;
|
||||
const next = base.filter((name) => name !== notifierName);
|
||||
if (selectedPriorities.has(priority)) next.push(notifierName);
|
||||
notificationRouting[priority] = unique(next);
|
||||
}
|
||||
|
||||
rawConfig["notificationRouting"] = notificationRouting;
|
||||
}
|
||||
|
||||
export function resolveRoutingPresetOption(
|
||||
value: string | undefined,
|
||||
label: string,
|
||||
): NotifierRoutingPreset | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = parseNotifierRoutingPreset(value);
|
||||
if (parsed) return parsed;
|
||||
throw new Error(`Invalid ${label} routing preset "${value}". Expected ${notifierRoutingPresetValues()}.`);
|
||||
}
|
||||
|
||||
export async function promptNotifierRoutingPreset(
|
||||
clack: ClackPrompts,
|
||||
rawConfig: Record<string, unknown>,
|
||||
notifierName: string,
|
||||
notifierLabel: string,
|
||||
cancel: () => never,
|
||||
): Promise<NotifierRoutingSelection> {
|
||||
const current = getNotifierRoutingState(rawConfig, notifierName);
|
||||
const choice = await clack.select({
|
||||
message: `Which notifications should ${notifierLabel} receive?`,
|
||||
initialValue: current.preset ?? "urgent-action",
|
||||
options: [
|
||||
...(current.hasRouting
|
||||
? [
|
||||
{
|
||||
value: "preserve",
|
||||
label: `Keep current routing (${current.label})`,
|
||||
hint: "Leave notificationRouting unchanged",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ value: "urgent-action", label: "Urgent + action", hint: "Recommended" },
|
||||
{ value: "urgent-only", label: "Urgent only" },
|
||||
{ value: "all", label: "All priorities" },
|
||||
{ value: "back", label: "Back", hint: "Return to the previous step" },
|
||||
{ value: "cancel", label: "Cancel setup", hint: "Do not change config" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice) || choice === "cancel") {
|
||||
cancel();
|
||||
}
|
||||
|
||||
if (choice === "preserve" || choice === "back") return choice;
|
||||
if (typeof choice === "string" && isNotifierRoutingPreset(choice)) return choice;
|
||||
return "urgent-action";
|
||||
}
|
||||
|
|
@ -0,0 +1,827 @@
|
|||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import {
|
||||
buildCIFailureNotificationData,
|
||||
buildPRStateNotificationData,
|
||||
buildReactionNotificationData,
|
||||
buildSessionTransitionNotificationData,
|
||||
createProjectObserver,
|
||||
recordNotificationDelivery,
|
||||
resolveNotifierTarget,
|
||||
type CICheck,
|
||||
type EventPriority,
|
||||
type EventType,
|
||||
type NotificationDataV3,
|
||||
type NotificationEventContext,
|
||||
type Notifier,
|
||||
type NotifyAction,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorEvent,
|
||||
type PluginRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
export const NOTIFY_TEST_TEMPLATE_NAMES = [
|
||||
"basic",
|
||||
"agent-stuck",
|
||||
"agent-needs-input",
|
||||
"agent-exited",
|
||||
"ci-failing",
|
||||
"review-changes-requested",
|
||||
"approved-and-green",
|
||||
"merge-ready",
|
||||
"all-complete",
|
||||
"pr-closed",
|
||||
] as const;
|
||||
|
||||
export type NotifyTestTemplateName = (typeof NOTIFY_TEST_TEMPLATE_NAMES)[number];
|
||||
|
||||
const VALID_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
const VALID_EVENT_TYPES = [
|
||||
"session.spawn_started",
|
||||
"session.spawned",
|
||||
"session.working",
|
||||
"session.exited",
|
||||
"session.killed",
|
||||
"session.idle",
|
||||
"session.stuck",
|
||||
"session.needs_input",
|
||||
"session.errored",
|
||||
"pr.created",
|
||||
"pr.updated",
|
||||
"pr.merged",
|
||||
"pr.closed",
|
||||
"ci.passing",
|
||||
"ci.failing",
|
||||
"ci.fix_sent",
|
||||
"ci.fix_failed",
|
||||
"review.pending",
|
||||
"review.approved",
|
||||
"review.changes_requested",
|
||||
"review.comments_sent",
|
||||
"review.comments_unresolved",
|
||||
"automated_review.found",
|
||||
"automated_review.fix_sent",
|
||||
"merge.ready",
|
||||
"merge.conflicts",
|
||||
"merge.completed",
|
||||
"reaction.triggered",
|
||||
"reaction.escalated",
|
||||
"summary.all_complete",
|
||||
] as const satisfies EventType[];
|
||||
|
||||
interface NotifyTemplate {
|
||||
type: EventType;
|
||||
priority: EventPriority;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
message: string;
|
||||
data: NotificationDataV3;
|
||||
}
|
||||
|
||||
const DEMO_PR_URL = "https://github.com/ComposioHQ/agent-orchestrator/pull/1579";
|
||||
|
||||
const DEMO_PR_CONTEXT: NotificationEventContext = {
|
||||
pr: {
|
||||
number: 1579,
|
||||
url: DEMO_PR_URL,
|
||||
title: "Normalize AO notifier payloads",
|
||||
branch: "ao/demo-notifier-harness",
|
||||
baseBranch: "main",
|
||||
owner: "ComposioHQ",
|
||||
repo: "agent-orchestrator",
|
||||
isDraft: false,
|
||||
},
|
||||
issueId: "AO-1579",
|
||||
issueTitle: "Make AO notification payloads API-grade",
|
||||
summary: "Normalize AO notifier payloads",
|
||||
branch: "ao/demo-notifier-harness",
|
||||
};
|
||||
|
||||
const DEMO_SYSTEM_CONTEXT: NotificationEventContext = {
|
||||
pr: null,
|
||||
issueId: null,
|
||||
issueTitle: null,
|
||||
summary: "AO notification delivery smoke test",
|
||||
branch: null,
|
||||
};
|
||||
|
||||
const DEMO_FAILED_CHECKS: CICheck[] = [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: `${DEMO_PR_URL}/checks?check_run_id=101`,
|
||||
},
|
||||
{
|
||||
name: "unit-tests",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: `${DEMO_PR_URL}/checks?check_run_id=102`,
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_TEMPLATES: Record<NotifyTestTemplateName, NotifyTemplate> = {
|
||||
basic: {
|
||||
type: "summary.all_complete",
|
||||
priority: "info",
|
||||
sessionId: "notify-demo",
|
||||
projectId: "demo",
|
||||
message: "Test notification from ao notify test",
|
||||
data: buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "notify-demo",
|
||||
projectId: "demo",
|
||||
context: DEMO_SYSTEM_CONTEXT,
|
||||
reactionKey: "all-complete",
|
||||
action: "notify",
|
||||
}),
|
||||
},
|
||||
"agent-stuck": {
|
||||
type: "session.stuck",
|
||||
priority: "urgent",
|
||||
sessionId: "demo-agent-7",
|
||||
projectId: "demo",
|
||||
message: "Agent demo-agent-7 appears stuck after repeated inactivity probes",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "session.stuck",
|
||||
sessionId: "demo-agent-7",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "working",
|
||||
newStatus: "stuck",
|
||||
}),
|
||||
},
|
||||
"agent-needs-input": {
|
||||
type: "session.needs_input",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-12",
|
||||
projectId: "demo",
|
||||
message: "Agent demo-agent-12 needs input before it can continue",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "session.needs_input",
|
||||
sessionId: "demo-agent-12",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "working",
|
||||
newStatus: "needs_input",
|
||||
}),
|
||||
},
|
||||
"agent-exited": {
|
||||
type: "session.killed",
|
||||
priority: "urgent",
|
||||
sessionId: "demo-agent-4",
|
||||
projectId: "demo",
|
||||
message: "Agent demo-agent-4 exited before completing its task",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "session.killed",
|
||||
sessionId: "demo-agent-4",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "working",
|
||||
newStatus: "killed",
|
||||
}),
|
||||
},
|
||||
"ci-failing": {
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-19",
|
||||
projectId: "demo",
|
||||
message: "CI is failing on PR #1579",
|
||||
data: buildCIFailureNotificationData({
|
||||
sessionId: "demo-agent-19",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
failedChecks: DEMO_FAILED_CHECKS,
|
||||
}),
|
||||
},
|
||||
"review-changes-requested": {
|
||||
type: "review.changes_requested",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-21",
|
||||
projectId: "demo",
|
||||
message: "Review changes were requested on PR #1579",
|
||||
data: (() => {
|
||||
const data = buildSessionTransitionNotificationData({
|
||||
eventType: "review.changes_requested",
|
||||
sessionId: "demo-agent-21",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "review_pending",
|
||||
newStatus: "changes_requested",
|
||||
});
|
||||
data.review = {
|
||||
...(data.review ?? {}),
|
||||
decision: "changes_requested",
|
||||
unresolvedThreads: 3,
|
||||
url: `${DEMO_PR_URL}#pullrequestreview-1`,
|
||||
};
|
||||
return data;
|
||||
})(),
|
||||
},
|
||||
"approved-and-green": {
|
||||
type: "reaction.triggered",
|
||||
priority: "info",
|
||||
sessionId: "demo-agent-23",
|
||||
projectId: "demo",
|
||||
message: "PR #1579 is approved and CI is green",
|
||||
data: buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "demo-agent-23",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
reactionKey: "approved-and-green",
|
||||
action: "notify",
|
||||
enrichment: {
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
title: "Normalize AO notifier payloads",
|
||||
hasConflicts: false,
|
||||
isBehind: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"merge-ready": {
|
||||
type: "merge.ready",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-29",
|
||||
projectId: "demo",
|
||||
message: "PR #1579 is ready to merge",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "merge.ready",
|
||||
sessionId: "demo-agent-29",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "approved",
|
||||
newStatus: "mergeable",
|
||||
enrichment: {
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
title: "Normalize AO notifier payloads",
|
||||
hasConflicts: false,
|
||||
isBehind: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"all-complete": {
|
||||
type: "summary.all_complete",
|
||||
priority: "info",
|
||||
sessionId: "demo-orchestrator",
|
||||
projectId: "demo",
|
||||
message: "All demo sessions completed successfully",
|
||||
data: buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "demo-orchestrator",
|
||||
projectId: "demo",
|
||||
context: DEMO_SYSTEM_CONTEXT,
|
||||
reactionKey: "all-complete",
|
||||
action: "notify",
|
||||
}),
|
||||
},
|
||||
"pr-closed": {
|
||||
type: "pr.closed",
|
||||
priority: "warning",
|
||||
sessionId: "demo-agent-31",
|
||||
projectId: "demo",
|
||||
message: "PR #1579 was closed without merge",
|
||||
data: buildPRStateNotificationData({
|
||||
eventType: "pr.closed",
|
||||
sessionId: "demo-agent-31",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldPRState: "open",
|
||||
newPRState: "closed",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const NOTIFY_TEST_ACTIONS: NotifyAction[] = [
|
||||
{
|
||||
label: "Open dashboard",
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
{
|
||||
label: "View PR",
|
||||
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
||||
},
|
||||
{
|
||||
label: "Acknowledge",
|
||||
callbackEndpoint: "http://localhost:3000/api/notifications/demo/ack",
|
||||
},
|
||||
];
|
||||
|
||||
export interface NotifyTestRequest {
|
||||
templateName?: string;
|
||||
to?: string[];
|
||||
all?: boolean;
|
||||
route?: string;
|
||||
actions?: boolean;
|
||||
message?: string;
|
||||
sessionId?: string;
|
||||
projectId?: string;
|
||||
priority?: string;
|
||||
type?: string;
|
||||
data?: Record<string, unknown>;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface NotifyTestTarget {
|
||||
reference: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export type NotifyDeliveryStatus = "sent" | "dry_run" | "failed" | "unresolved";
|
||||
|
||||
export interface NotifyDeliveryResult {
|
||||
reference: string;
|
||||
pluginName: string;
|
||||
status: NotifyDeliveryStatus;
|
||||
method: "notify" | "notifyWithActions" | null;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NotifyTestResult {
|
||||
ok: boolean;
|
||||
dryRun: boolean;
|
||||
templateName: NotifyTestTemplateName;
|
||||
event: OrchestratorEvent;
|
||||
actions: NotifyAction[];
|
||||
targets: NotifyTestTarget[];
|
||||
deliveries: NotifyDeliveryResult[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface NotifySinkRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
json: unknown;
|
||||
}
|
||||
|
||||
export interface NotifySinkServer {
|
||||
port: number;
|
||||
url: string;
|
||||
requests: NotifySinkRequest[];
|
||||
waitForRequest(timeoutMs?: number): Promise<NotifySinkRequest | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class NotifyTestError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "NotifyTestError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function assertTemplateName(name: string | undefined): NotifyTestTemplateName {
|
||||
const candidate = name ?? "basic";
|
||||
if (isTemplateName(candidate)) return candidate;
|
||||
throw new NotifyTestError(
|
||||
"invalid_template",
|
||||
`Unknown template "${candidate}". Expected one of: ${NOTIFY_TEST_TEMPLATE_NAMES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isTemplateName(value: string): value is NotifyTestTemplateName {
|
||||
return NOTIFY_TEST_TEMPLATE_NAMES.includes(value as NotifyTestTemplateName);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function syncNotificationSubject(
|
||||
data: Record<string, unknown>,
|
||||
sessionId: string,
|
||||
projectId: string,
|
||||
): Record<string, unknown> {
|
||||
if (data.schemaVersion !== 3 || !isRecord(data.subject)) return data;
|
||||
|
||||
return {
|
||||
...data,
|
||||
subject: {
|
||||
...data.subject,
|
||||
session: { id: sessionId, projectId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertPriority(priority: string, source: string): EventPriority {
|
||||
if ((VALID_PRIORITIES as readonly string[]).includes(priority)) {
|
||||
return priority as EventPriority;
|
||||
}
|
||||
throw new NotifyTestError(
|
||||
"invalid_priority",
|
||||
`Invalid ${source} "${priority}". Expected one of: ${VALID_PRIORITIES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertEventType(type: string): EventType {
|
||||
if ((VALID_EVENT_TYPES as readonly string[]).includes(type)) {
|
||||
return type as EventType;
|
||||
}
|
||||
throw new NotifyTestError(
|
||||
"invalid_event_type",
|
||||
`Invalid event type "${type}". Expected one of: ${VALID_EVENT_TYPES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueRefs(refs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const ref of refs) {
|
||||
const normalized = ref.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function refsFromConfiguredAndDefaults(config: OrchestratorConfig): string[] {
|
||||
return uniqueRefs([
|
||||
...Object.keys(config.notifiers ?? {}),
|
||||
...(config.defaults?.notifiers ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function refsFromAllKnownSources(config: OrchestratorConfig): string[] {
|
||||
return uniqueRefs([
|
||||
...Object.keys(config.notifiers ?? {}),
|
||||
...(config.defaults?.notifiers ?? []),
|
||||
...Object.values(config.notificationRouting ?? {}).flat(),
|
||||
]);
|
||||
}
|
||||
|
||||
function refsFromRoute(config: OrchestratorConfig, priority: EventPriority): string[] {
|
||||
return uniqueRefs(config.notificationRouting?.[priority] ?? config.defaults?.notifiers ?? []);
|
||||
}
|
||||
|
||||
export function createNotifyTestEvent(request: NotifyTestRequest = {}): {
|
||||
templateName: NotifyTestTemplateName;
|
||||
event: OrchestratorEvent;
|
||||
} {
|
||||
const templateName = assertTemplateName(request.templateName);
|
||||
const template = DEMO_TEMPLATES[templateName];
|
||||
const priority = request.priority
|
||||
? assertPriority(request.priority, "priority")
|
||||
: template.priority;
|
||||
const type = request.type ? assertEventType(request.type) : template.type;
|
||||
const sessionId = request.sessionId ?? template.sessionId;
|
||||
const projectId = request.projectId ?? template.projectId;
|
||||
const data = syncNotificationSubject(
|
||||
{
|
||||
...template.data,
|
||||
...(request.data ?? {}),
|
||||
},
|
||||
sessionId,
|
||||
projectId,
|
||||
);
|
||||
|
||||
return {
|
||||
templateName,
|
||||
event: {
|
||||
id: `notify-test-${Date.now()}`,
|
||||
type,
|
||||
priority,
|
||||
sessionId,
|
||||
projectId,
|
||||
timestamp: new Date(),
|
||||
message: request.message ?? template.message,
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNotifyTestTargets(
|
||||
config: OrchestratorConfig,
|
||||
eventPriority: EventPriority,
|
||||
request: NotifyTestRequest = {},
|
||||
): NotifyTestTarget[] {
|
||||
const refs = (() => {
|
||||
if (request.to && request.to.length > 0) {
|
||||
return uniqueRefs(request.to);
|
||||
}
|
||||
if (request.all) {
|
||||
return refsFromAllKnownSources(config);
|
||||
}
|
||||
if (request.route) {
|
||||
return refsFromRoute(config, assertPriority(request.route, "route"));
|
||||
}
|
||||
|
||||
const routedRefs = refsFromRoute(config, eventPriority);
|
||||
return routedRefs.length > 0 ? routedRefs : refsFromConfiguredAndDefaults(config);
|
||||
})();
|
||||
|
||||
return refs.map((ref) => {
|
||||
const target = resolveNotifierTarget(config, ref);
|
||||
return {
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function runNotifyTest(
|
||||
config: OrchestratorConfig,
|
||||
registry: PluginRegistry,
|
||||
request: NotifyTestRequest = {},
|
||||
): Promise<NotifyTestResult> {
|
||||
const { templateName, event } = createNotifyTestEvent(request);
|
||||
const targets = resolveNotifyTestTargets(config, event.priority, request);
|
||||
const actions = request.actions ? [...NOTIFY_TEST_ACTIONS] : [];
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const deliveries: NotifyDeliveryResult[] = [];
|
||||
const observer = request.dryRun ? null : createProjectObserver(config, "notify-test");
|
||||
|
||||
if (targets.length === 0) {
|
||||
errors.push(
|
||||
"No notifier targets resolved. Configure notifiers or pass --to, --all, or --sink.",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
dryRun: Boolean(request.dryRun),
|
||||
templateName,
|
||||
event,
|
||||
actions,
|
||||
targets,
|
||||
deliveries,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const notifier =
|
||||
registry.get<Notifier>("notifier", target.reference) ??
|
||||
registry.get<Notifier>("notifier", target.pluginName);
|
||||
|
||||
if (!notifier) {
|
||||
const error = `${target.reference}: notifier plugin "${target.pluginName}" is not loaded`;
|
||||
errors.push(error);
|
||||
if (observer) {
|
||||
recordNotificationDelivery({
|
||||
observer,
|
||||
event,
|
||||
target,
|
||||
outcome: "failure",
|
||||
method: "notify",
|
||||
reason: "notifier target not found",
|
||||
failureKind: "target_missing",
|
||||
recordActivityEvent: true,
|
||||
});
|
||||
}
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "unresolved",
|
||||
method: null,
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (request.dryRun) {
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "dry_run",
|
||||
method: actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const method =
|
||||
actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify";
|
||||
if (actions.length > 0 && notifier.notifyWithActions) {
|
||||
await notifier.notifyWithActions(event, actions);
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "sent",
|
||||
method: "notifyWithActions",
|
||||
});
|
||||
} else {
|
||||
const warning =
|
||||
actions.length > 0
|
||||
? `${target.reference}: notifyWithActions() is unavailable; sent with notify()`
|
||||
: undefined;
|
||||
if (warning) warnings.push(warning);
|
||||
|
||||
await notifier.notify(event);
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "sent",
|
||||
method: "notify",
|
||||
warning,
|
||||
});
|
||||
}
|
||||
if (observer) {
|
||||
recordNotificationDelivery({
|
||||
observer,
|
||||
event,
|
||||
target,
|
||||
outcome: "success",
|
||||
method,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = `${target.reference}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
const method =
|
||||
actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify";
|
||||
errors.push(error);
|
||||
if (observer) {
|
||||
recordNotificationDelivery({
|
||||
observer,
|
||||
event,
|
||||
target,
|
||||
outcome: "failure",
|
||||
method,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
failureKind: "delivery_failed",
|
||||
recordActivityEvent: true,
|
||||
});
|
||||
}
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "failed",
|
||||
method,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
dryRun: Boolean(request.dryRun),
|
||||
templateName,
|
||||
event,
|
||||
actions,
|
||||
targets,
|
||||
deliveries,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNotifyDataJson(
|
||||
input: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!input) return undefined;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new NotifyTestError("invalid_json", `Invalid --data JSON: ${message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new NotifyTestError("invalid_json", "--data must be a JSON object");
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseNotifyRefs(input: string | undefined): string[] | undefined {
|
||||
if (!input) return undefined;
|
||||
return uniqueRefs(input.split(","));
|
||||
}
|
||||
|
||||
export function parseSinkPort(input: true | string | undefined): number | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
if (input === true) return 0;
|
||||
|
||||
const port = Number(input);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new NotifyTestError("invalid_sink_port", `Invalid --sink port "${input}"`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export function addSinkNotifierConfig(
|
||||
config: OrchestratorConfig,
|
||||
sinkUrl: string,
|
||||
): OrchestratorConfig {
|
||||
return {
|
||||
...config,
|
||||
defaults: {
|
||||
...config.defaults,
|
||||
notifiers: uniqueRefs(["sink", ...(config.defaults?.notifiers ?? [])]),
|
||||
},
|
||||
notifiers: {
|
||||
...(config.notifiers ?? {}),
|
||||
sink: {
|
||||
plugin: "webhook",
|
||||
url: sinkUrl,
|
||||
retries: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function respond(res: ServerResponse, statusCode: number, body = ""): void {
|
||||
res.statusCode = statusCode;
|
||||
if (body) {
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
}
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export async function startNotifySink(port = 0): Promise<NotifySinkServer> {
|
||||
const requests: NotifySinkRequest[] = [];
|
||||
const waiters: Array<(request: NotifySinkRequest | null) => void> = [];
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
respond(res, 405, "method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readRequestBody(req);
|
||||
const json = (() => {
|
||||
try {
|
||||
return body ? (JSON.parse(body) as unknown) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const request: NotifySinkRequest = {
|
||||
method: req.method,
|
||||
url: req.url ?? "/",
|
||||
headers: req.headers,
|
||||
body,
|
||||
json,
|
||||
};
|
||||
|
||||
requests.push(request);
|
||||
const pending = waiters.splice(0);
|
||||
for (const waiter of pending) waiter(request);
|
||||
respond(res, 204);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
waitForRequest(timeoutMs = 1000): Promise<NotifySinkRequest | null> {
|
||||
if (requests[0]) return Promise.resolve(requests[0]);
|
||||
return new Promise((resolve) => {
|
||||
const waiter = (request: NotifySinkRequest | null) => {
|
||||
clearTimeout(timer);
|
||||
resolve(request);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
},
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
closeServer(server, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function closeServer(server: Server, callback: (err?: Error) => void): void {
|
||||
server.close((err) => callback(err ?? undefined));
|
||||
}
|
||||
|
|
@ -0,0 +1,889 @@
|
|||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
ensureNotifierDefault,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
routingLabel,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
import {
|
||||
DEFAULT_OPENCLAW_URL,
|
||||
HOOKS_PATH,
|
||||
detectOpenClawInstallation,
|
||||
probeGateway,
|
||||
validateToken,
|
||||
} from "./openclaw-probe.js";
|
||||
|
||||
export type OpenClawRoutingPreset = NotifierRoutingPreset;
|
||||
|
||||
export interface OpenClawSetupOptions {
|
||||
url?: string;
|
||||
token?: string;
|
||||
openclawConfigPath?: string;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TokenInfo {
|
||||
value: string;
|
||||
source: "cli" | "env" | "yaml" | "openclaw-config" | "manual";
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
interface ResolvedOpenClawSetup {
|
||||
url: string;
|
||||
token: string;
|
||||
openclawConfigPath: string;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
shouldSendTest: boolean;
|
||||
tokenSource: TokenInfo["source"];
|
||||
}
|
||||
|
||||
const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const DISPLAY_OPENCLAW_CONFIG_PATH = "~/.openclaw/openclaw.json";
|
||||
|
||||
export class OpenClawSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OpenClawSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeOpenClawHooksUrl(url: string): string {
|
||||
const normalized = url.trim().replace(/\/+$/, "");
|
||||
return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`;
|
||||
}
|
||||
|
||||
function expandHomePath(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function displayOpenClawConfigPath(path: string): string {
|
||||
const expanded = expandHomePath(path);
|
||||
return expanded === DEFAULT_OPENCLAW_CONFIG_PATH ? DISPLAY_OPENCLAW_CONFIG_PATH : path;
|
||||
}
|
||||
|
||||
function validateOpenClawUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new OpenClawSetupError("OpenClaw webhook URL is invalid.");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new OpenClawSetupError("OpenClaw webhook URL must start with http:// or https://.");
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new OpenClawSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingOpenClaw(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["openclaw"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function getOpenClawJsonPath(): string {
|
||||
return DEFAULT_OPENCLAW_CONFIG_PATH;
|
||||
}
|
||||
|
||||
function readOpenClawJson(configPath: string = DEFAULT_OPENCLAW_CONFIG_PATH): {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
config: Record<string, unknown>;
|
||||
token?: string;
|
||||
} {
|
||||
const path = expandHomePath(configPath);
|
||||
try {
|
||||
if (!existsSync(path)) return { path, exists: false, config: {} };
|
||||
const config = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
const hooks = isRecord(config["hooks"]) ? config["hooks"] : {};
|
||||
return {
|
||||
path,
|
||||
exists: true,
|
||||
config,
|
||||
token: stringValue(hooks["token"]),
|
||||
};
|
||||
} catch {
|
||||
return { path, exists: existsSync(path), config: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredOpenClawConfigPath(
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
): string {
|
||||
return (
|
||||
stringValue(opts.openclawConfigPath) ??
|
||||
stringValue(existingOpenClaw["openclawConfigPath"]) ??
|
||||
stringValue(existingOpenClaw["configPath"]) ??
|
||||
getOpenClawJsonPath()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConfiguredToken(
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
openclawConfigPath: string,
|
||||
): TokenInfo | undefined {
|
||||
const openclawJson = readOpenClawJson(openclawConfigPath);
|
||||
if (openclawJson.token) {
|
||||
return {
|
||||
value: openclawJson.token,
|
||||
source: "openclaw-config",
|
||||
configPath: openclawJson.path,
|
||||
};
|
||||
}
|
||||
|
||||
const rawYamlToken = stringValue(existingOpenClaw["token"]);
|
||||
if (rawYamlToken) {
|
||||
const envVarMatch = rawYamlToken.match(/^\$\{([^}]+)\}$/);
|
||||
if (envVarMatch) {
|
||||
const envValue = process.env[envVarMatch[1]];
|
||||
if (envValue) return { value: envValue, source: "env", configPath: openclawJson.path };
|
||||
} else {
|
||||
return { value: rawYamlToken, source: "yaml", configPath: openclawJson.path };
|
||||
}
|
||||
}
|
||||
|
||||
const envToken = process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
if (envToken) return { value: envToken, source: "env", configPath: openclawJson.path };
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingOpenClaw(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "openclaw" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new OpenClawSetupError(
|
||||
`notifiers.openclaw already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.openclaw already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing OpenClaw notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new OpenClawSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
function printOpenClawStartInstructions(): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("Start or install OpenClaw"));
|
||||
console.log(" 1. Start your local OpenClaw gateway.");
|
||||
console.log(" 2. Confirm the gateway URL. The default is http://127.0.0.1:18789.");
|
||||
console.log(" 3. Paste the hooks URL here. AO will normalize it to /hooks/agent.");
|
||||
console.log(chalk.dim("If OpenClaw runs elsewhere, use that machine's gateway URL."));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printOpenClawTokenInstructions(openclawConfigPath: string): void {
|
||||
const displayPath = displayOpenClawConfigPath(openclawConfigPath);
|
||||
console.log("");
|
||||
console.log(chalk.bold("Configure the OpenClaw hooks token"));
|
||||
console.log(` 1. Open your OpenClaw config: ${displayPath}`);
|
||||
console.log(" 2. In OpenClaw's webhook/hooks settings, create or copy the hooks token.");
|
||||
console.log(" 3. Put that token in hooks.token and make sure hooks are enabled:");
|
||||
console.log("");
|
||||
console.log(
|
||||
chalk.cyan(`{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"token": "<openclaw-hooks-token>",
|
||||
"allowRequestSessionKey": true,
|
||||
"allowedSessionKeyPrefixes": ["hook:"]
|
||||
}
|
||||
}`),
|
||||
);
|
||||
console.log("");
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"OpenClaw requires this shared secret for POST /hooks/agent. AO reads it from the OpenClaw config and does not generate or store it in your shell profile.",
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim("Restart OpenClaw after changing the config."));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function promptOpenClawUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const urlInput = await clack.text({
|
||||
message: "OpenClaw webhook URL:",
|
||||
placeholder: `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`,
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "OpenClaw webhook URL is required";
|
||||
try {
|
||||
validateOpenClawUrl(normalizeOpenClawHooksUrl(String(value)));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(urlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return normalizeOpenClawHooksUrl(String(urlInput));
|
||||
}
|
||||
|
||||
async function promptAfterOpenClawInstructions(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
printOpenClawStartInstructions();
|
||||
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "What do you want to do next?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Enter OpenClaw URL",
|
||||
hint: "Paste the local or remote gateway URL",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous menu",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "enter-url") return promptOpenClawUrl(clack, initialValue);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: OpenClawSetupOptions,
|
||||
existingUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedUrl = stringValue(opts.url);
|
||||
if (providedUrl) {
|
||||
const normalized = normalizeOpenClawHooksUrl(providedUrl);
|
||||
validateOpenClawUrl(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const defaultHooksUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`;
|
||||
let detectedUrl: string | undefined;
|
||||
const spin = clack.spinner();
|
||||
spin.start("Detecting OpenClaw gateway on localhost...");
|
||||
const probe = await probeGateway(DEFAULT_OPENCLAW_URL);
|
||||
if (probe.reachable) {
|
||||
detectedUrl = defaultHooksUrl;
|
||||
spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`);
|
||||
} else {
|
||||
spin.stop("No OpenClaw gateway detected on localhost");
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const source = existingUrl
|
||||
? await clack.select({
|
||||
message: "OpenClaw notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing gateway URL",
|
||||
hint: existingUrl,
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Change gateway URL",
|
||||
hint: "Paste a different OpenClaw gateway URL",
|
||||
},
|
||||
{
|
||||
value: "use-local",
|
||||
label: "Use local default URL",
|
||||
hint: defaultHooksUrl,
|
||||
},
|
||||
{
|
||||
value: "need-openclaw",
|
||||
label: "Show OpenClaw setup steps",
|
||||
hint: "AO will print the local gateway requirements",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
})
|
||||
: await clack.select({
|
||||
message: "How do you want to point AO at OpenClaw?",
|
||||
options: [
|
||||
{
|
||||
value: "use-local",
|
||||
label: probe.reachable ? "Use detected local gateway" : "Use local default URL",
|
||||
hint: detectedUrl ?? defaultHooksUrl,
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Enter a different URL",
|
||||
hint: "Use this when OpenClaw runs elsewhere",
|
||||
},
|
||||
{
|
||||
value: "need-openclaw",
|
||||
label: "Show OpenClaw setup steps",
|
||||
hint: "AO will print the local gateway requirements",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing" && existingUrl) return normalizeOpenClawHooksUrl(existingUrl);
|
||||
if (source === "use-local") return detectedUrl ?? defaultHooksUrl;
|
||||
if (source === "change-url") return promptOpenClawUrl(clack, existingUrl ?? detectedUrl);
|
||||
if (source === "need-openclaw") {
|
||||
const result = await promptAfterOpenClawInstructions(clack, existingUrl ?? detectedUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveToken(
|
||||
clack: ClackPrompts,
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
initialOpenClawConfigPath: string,
|
||||
): Promise<TokenInfo | "back"> {
|
||||
const providedToken = stringValue(opts.token);
|
||||
if (providedToken) {
|
||||
return { value: providedToken, source: "cli", configPath: expandHomePath(initialOpenClawConfigPath) };
|
||||
}
|
||||
|
||||
let openclawConfigPath = initialOpenClawConfigPath;
|
||||
while (true) {
|
||||
const existingToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
const options = [
|
||||
...(existingToken
|
||||
? [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: `Use existing token from ${existingToken.source}`,
|
||||
hint:
|
||||
existingToken.source === "openclaw-config"
|
||||
? displayOpenClawConfigPath(existingToken.configPath)
|
||||
: "Legacy fallback; AO will not write shell exports",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: "check-config",
|
||||
label: existingToken ? "Check OpenClaw config again" : "I added hooks.token to OpenClaw config",
|
||||
hint: displayOpenClawConfigPath(openclawConfigPath),
|
||||
},
|
||||
{
|
||||
value: "show-steps",
|
||||
label: "Show where to configure the token",
|
||||
hint: "Print OpenClaw-side config steps",
|
||||
},
|
||||
{
|
||||
value: "manual",
|
||||
label: "Enter token manually",
|
||||
hint: "For remote OpenClaw only; AO stores it in agent-orchestrator.yaml",
|
||||
},
|
||||
{
|
||||
value: "config-path",
|
||||
label: "Use a different OpenClaw config path",
|
||||
hint: "Read hooks.token from another local OpenClaw config",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to gateway URL",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
];
|
||||
|
||||
const choice = await clack.select({
|
||||
message: "How should AO configure the OpenClaw hooks token?",
|
||||
options,
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice) || choice === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (choice === "back") return "back";
|
||||
if (choice === "use-existing" && existingToken) return existingToken;
|
||||
if (choice === "check-config") {
|
||||
const refreshedToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
if (refreshedToken) return refreshedToken;
|
||||
clack.log.warn(
|
||||
`No hooks.token found in ${displayOpenClawConfigPath(openclawConfigPath)} yet.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (choice === "show-steps") {
|
||||
printOpenClawTokenInstructions(openclawConfigPath);
|
||||
continue;
|
||||
}
|
||||
if (choice === "config-path") {
|
||||
const pathInput = await clack.text({
|
||||
message: "OpenClaw config path:",
|
||||
placeholder: DISPLAY_OPENCLAW_CONFIG_PATH,
|
||||
initialValue: displayOpenClawConfigPath(openclawConfigPath),
|
||||
validate: (value) => (!value ? "OpenClaw config path is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(pathInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
openclawConfigPath = String(pathInput);
|
||||
continue;
|
||||
}
|
||||
if (choice === "manual") {
|
||||
const input = await clack.password({
|
||||
message: "OpenClaw hooks token:",
|
||||
validate: (value) => (!value ? "Token is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(input)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
return { value: String(input), source: "manual", configPath: expandHomePath(openclawConfigPath) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveRoutingPreset(
|
||||
clack: ClackPrompts,
|
||||
opts: OpenClawSetupOptions,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<OpenClawRoutingPreset | undefined | "back"> {
|
||||
const optionPreset = resolveOpenClawRoutingPreset(opts.routingPreset);
|
||||
if (optionPreset) return optionPreset;
|
||||
|
||||
const selection = await promptNotifierRoutingPreset(
|
||||
clack,
|
||||
rawConfig,
|
||||
"openclaw",
|
||||
"OpenClaw",
|
||||
() => cancelSetup(clack),
|
||||
);
|
||||
if (selection === "preserve") return undefined;
|
||||
return selection;
|
||||
}
|
||||
|
||||
function resolveOpenClawRoutingPreset(value: string | undefined): OpenClawRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "OpenClaw") as OpenClawRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new OpenClawSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function printReview(resolved: ResolvedOpenClawSetup): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("OpenClaw setup review"));
|
||||
console.log(` Webhook URL: ${resolved.url}`);
|
||||
console.log(` Token: configured from ${resolved.tokenSource}`);
|
||||
console.log(` OpenClaw config: ${displayOpenClawConfigPath(resolved.openclawConfigPath)}`);
|
||||
console.log(` Routing: ${resolved.routingPreset ? routingLabel(resolved.routingPreset) : "unchanged"}`);
|
||||
console.log(` Setup probe: ${resolved.shouldSendTest ? "enabled" : "skipped"}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function promptInteractiveReview(
|
||||
clack: ClackPrompts,
|
||||
resolved: ResolvedOpenClawSetup,
|
||||
): Promise<"save" | "url" | "token" | "routing"> {
|
||||
printReview(resolved);
|
||||
const choice = await clack.select({
|
||||
message: "Save this OpenClaw setup?",
|
||||
options: [
|
||||
{ value: "save", label: "Save setup" },
|
||||
{ value: "url", label: "Change gateway URL" },
|
||||
{ value: "token", label: "Change token" },
|
||||
{ value: "routing", label: "Change routing" },
|
||||
{ value: "cancel", label: "Cancel setup", hint: "Do not change config" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice) || choice === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return choice as "save" | "url" | "token" | "routing";
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedOpenClawSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingUrl = stringValue(existingOpenClaw["url"]);
|
||||
const initialOpenClawConfigPath = getConfiguredOpenClawConfigPath(opts, existingOpenClaw);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw ")));
|
||||
|
||||
let url: string | undefined;
|
||||
let tokenInfo: TokenInfo | undefined;
|
||||
let routingPreset: OpenClawRoutingPreset | undefined;
|
||||
let step: "url" | "token" | "routing" | "review" = "url";
|
||||
|
||||
while (true) {
|
||||
if (step === "url") {
|
||||
url = await resolveInteractiveUrl(clack, opts, existingUrl);
|
||||
step = "token";
|
||||
}
|
||||
|
||||
if (step === "token") {
|
||||
const selectedTokenInfo = await resolveInteractiveToken(
|
||||
clack,
|
||||
opts,
|
||||
existingOpenClaw,
|
||||
tokenInfo?.configPath ?? initialOpenClawConfigPath,
|
||||
);
|
||||
if (selectedTokenInfo === "back") {
|
||||
step = "url";
|
||||
continue;
|
||||
}
|
||||
tokenInfo = selectedTokenInfo;
|
||||
step = "routing";
|
||||
}
|
||||
|
||||
if (step === "routing") {
|
||||
const selectedRoutingPreset = await resolveInteractiveRoutingPreset(
|
||||
clack,
|
||||
opts,
|
||||
rawConfig,
|
||||
);
|
||||
if (selectedRoutingPreset === "back") {
|
||||
step = "token";
|
||||
continue;
|
||||
}
|
||||
routingPreset = selectedRoutingPreset;
|
||||
step = "review";
|
||||
}
|
||||
|
||||
if (step === "review") {
|
||||
const resolved = {
|
||||
url: url ?? `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`,
|
||||
token: tokenInfo?.value ?? "",
|
||||
openclawConfigPath: tokenInfo?.configPath ?? expandHomePath(initialOpenClawConfigPath),
|
||||
routingPreset,
|
||||
shouldSendTest: opts.test !== false,
|
||||
tokenSource: tokenInfo?.source ?? "manual",
|
||||
} satisfies ResolvedOpenClawSetup;
|
||||
|
||||
const next = await promptInteractiveReview(clack, resolved);
|
||||
if (next === "save") return resolved;
|
||||
step = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNonInteractiveSetup(
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
_rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedOpenClawSetup> {
|
||||
let rawUrl =
|
||||
stringValue(opts.url) ??
|
||||
process.env["OPENCLAW_GATEWAY_URL"] ??
|
||||
(opts.refresh ? stringValue(existingOpenClaw["url"]) : undefined);
|
||||
|
||||
if (!rawUrl) {
|
||||
const installation = await detectOpenClawInstallation();
|
||||
if (installation.state === "running") {
|
||||
rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`;
|
||||
console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`));
|
||||
} else {
|
||||
throw new OpenClawSetupError(
|
||||
"Error: OpenClaw gateway not reachable and no --url provided.\n" +
|
||||
" Start OpenClaw first, or pass --url explicitly:\n" +
|
||||
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --openclaw-config-path ~/.openclaw/openclaw.json --non-interactive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const url = normalizeOpenClawHooksUrl(rawUrl);
|
||||
validateOpenClawUrl(url);
|
||||
|
||||
const openclawConfigPath = getConfiguredOpenClawConfigPath(opts, existingOpenClaw);
|
||||
const configuredToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
const tokenInfo =
|
||||
stringValue(opts.token) !== undefined
|
||||
? ({
|
||||
value: stringValue(opts.token) as string,
|
||||
source: "cli",
|
||||
configPath: expandHomePath(openclawConfigPath),
|
||||
} satisfies TokenInfo)
|
||||
: configuredToken;
|
||||
|
||||
if (!tokenInfo) {
|
||||
throw new OpenClawSetupError(
|
||||
`No OpenClaw hooks token found in ${displayOpenClawConfigPath(openclawConfigPath)}.\n` +
|
||||
" Generate or copy the hooks token from OpenClaw, put it in hooks.token, then rerun setup.\n" +
|
||||
` Config example: ${displayOpenClawConfigPath(openclawConfigPath)} -> hooks.token\n` +
|
||||
" For remote OpenClaw only, pass --token explicitly.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.dim("Skipping setup probe in non-interactive mode. Run `ao setup openclaw --status` to verify."));
|
||||
|
||||
const routingPreset = resolveOpenClawRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "urgent-action");
|
||||
|
||||
return {
|
||||
url,
|
||||
token: tokenInfo.value,
|
||||
openclawConfigPath: tokenInfo.configPath,
|
||||
routingPreset,
|
||||
shouldSendTest: opts.test !== false,
|
||||
tokenSource: tokenInfo.source,
|
||||
};
|
||||
}
|
||||
|
||||
function writeOpenClawConfig(
|
||||
configPath: string,
|
||||
resolved: ResolvedOpenClawSetup,
|
||||
nonInteractive: boolean,
|
||||
): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const openclawConfig: Record<string, unknown> = {
|
||||
plugin: "openclaw",
|
||||
url: resolved.url,
|
||||
openclawConfigPath: displayOpenClawConfigPath(resolved.openclawConfigPath),
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
wakeMode: "now",
|
||||
};
|
||||
if (resolved.tokenSource === "cli" || resolved.tokenSource === "manual" || resolved.tokenSource === "yaml") {
|
||||
openclawConfig["token"] = resolved.token;
|
||||
} else if (resolved.tokenSource === "env") {
|
||||
openclawConfig["token"] = "$" + "{OPENCLAW_HOOKS_TOKEN}";
|
||||
}
|
||||
notifiers["openclaw"] = openclawConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
if (resolved.routingPreset) {
|
||||
ensureNotifierDefault(rawConfig, "openclaw");
|
||||
}
|
||||
applyNotifierRoutingPreset(rawConfig, "openclaw", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log(chalk.green(`✓ Config written to ${configPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
function printOpenClawInstructions(nonInteractive: boolean, resolved: ResolvedOpenClawSetup): void {
|
||||
const tokenLocation =
|
||||
resolved.tokenSource === "openclaw-config"
|
||||
? displayOpenClawConfigPath(resolved.openclawConfigPath)
|
||||
: resolved.tokenSource === "env"
|
||||
? "OPENCLAW_HOOKS_TOKEN"
|
||||
: "agent-orchestrator.yaml";
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log(chalk.green("✓ AO config written (OpenClaw config left unchanged)"));
|
||||
console.log(`Token source: ${tokenLocation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${chalk.green.bold("Done — AO config written.")}`);
|
||||
console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block"));
|
||||
console.log(chalk.dim(` token source — ${tokenLocation}`));
|
||||
if (resolved.tokenSource === "openclaw-config") {
|
||||
console.log(chalk.dim(" AO did not write OpenClaw config or shell profile exports."));
|
||||
}
|
||||
}
|
||||
|
||||
async function runInteractiveSetupProbe(resolved: ResolvedOpenClawSetup): Promise<void> {
|
||||
if (!resolved.shouldSendTest) {
|
||||
console.log(chalk.dim("Skipped OpenClaw setup probe."));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateToken(resolved.url, resolved.token);
|
||||
if (result.valid) {
|
||||
console.log(chalk.green("✓ OpenClaw setup probe passed"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`OpenClaw setup probe did not pass yet: ${result.error ?? "unknown validation error"}`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim("Restart OpenClaw, then run `ao setup openclaw --status` to verify."));
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingOpenClaw = getExistingOpenClaw(context.rawConfig);
|
||||
const plugin = stringValue(existingOpenClaw["plugin"]);
|
||||
const configuredUrl = stringValue(existingOpenClaw["url"]);
|
||||
const url = configuredUrl ? normalizeOpenClawHooksUrl(configuredUrl) : DEFAULT_OPENCLAW_URL;
|
||||
const openclawConfigPath = getConfiguredOpenClawConfigPath({}, existingOpenClaw);
|
||||
const tokenInfo = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
const openclawJson = readOpenClawJson(openclawConfigPath);
|
||||
const installation = await detectOpenClawInstallation(url);
|
||||
|
||||
console.log(chalk.bold("OpenClaw notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Webhook URL: ${configuredUrl ?? chalk.dim("not configured")}`);
|
||||
console.log(` Token: ${tokenInfo ? `configured from ${tokenInfo.source}` : chalk.dim("not configured")}`);
|
||||
console.log(
|
||||
` OpenClaw config: ${openclawJson.exists ? displayOpenClawConfigPath(openclawJson.path) : chalk.dim(`${displayOpenClawConfigPath(openclawJson.path)} not found`)}`,
|
||||
);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "openclaw").label}`);
|
||||
console.log(` Gateway: ${installation.state} at ${installation.gatewayUrl}`);
|
||||
if (installation.binaryPath) console.log(` Binary: ${installation.binaryPath}`);
|
||||
|
||||
if (plugin !== "openclaw" || !tokenInfo || installation.state !== "running") return;
|
||||
|
||||
const validation = await validateToken(url, tokenInfo.value);
|
||||
if (validation.valid) {
|
||||
console.log(chalk.green(" Token probe: PASS"));
|
||||
} else {
|
||||
console.log(chalk.red(` Token probe: FAIL ${validation.error ?? "unknown error"}`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawSetupAction(opts: OpenClawSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingOpenClaw = getExistingOpenClaw(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingOpenClaw(
|
||||
existingOpenClaw["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? await resolveNonInteractiveSetup(opts, existingOpenClaw, context.rawConfig)
|
||||
: await resolveInteractiveSetup(opts, existingOpenClaw, context.rawConfig);
|
||||
|
||||
writeOpenClawConfig(context.configPath, resolved, nonInteractive);
|
||||
|
||||
if (!nonInteractive) {
|
||||
await runInteractiveSetupProbe(resolved);
|
||||
}
|
||||
|
||||
printOpenClawInstructions(nonInteractive, resolved);
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("OpenClaw setup complete!")} AO will send notifications to OpenClaw.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to openclaw --template basic\n") +
|
||||
chalk.dim(" Restart AO with 'ao stop && ao start' to activate."),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ OpenClaw setup complete."));
|
||||
console.log(chalk.dim("Restart AO to activate: ao stop && ao start"));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import codexPlugin from "@aoagents/ao-plugin-agent-codex";
|
|||
import aiderPlugin from "@aoagents/ao-plugin-agent-aider";
|
||||
import cursorPlugin from "@aoagents/ao-plugin-agent-cursor";
|
||||
import kimicodePlugin from "@aoagents/ao-plugin-agent-kimicode";
|
||||
import grokPlugin from "@aoagents/ao-plugin-agent-grok";
|
||||
import opencodePlugin from "@aoagents/ao-plugin-agent-opencode";
|
||||
import githubSCMPlugin from "@aoagents/ao-plugin-scm-github";
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ const agentPlugins: Record<string, { create(): Agent }> = {
|
|||
aider: aiderPlugin,
|
||||
cursor: cursorPlugin,
|
||||
kimicode: kimicodePlugin,
|
||||
grok: grokPlugin,
|
||||
opencode: opencodePlugin,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isTerminalSession,
|
||||
createCorrelationId,
|
||||
createProjectObserver,
|
||||
ConfigNotFoundError,
|
||||
type OrchestratorConfig,
|
||||
type ProjectObserver,
|
||||
} from "@aoagents/ao-core";
|
||||
|
|
@ -24,11 +25,36 @@ interface SupervisorHandle {
|
|||
|
||||
let activeSupervisor: SupervisorHandle | null = null;
|
||||
|
||||
export interface ReconcileProjectSupervisorOptions {
|
||||
intervalMs?: number;
|
||||
type SupervisorConfigSource = "global" | "local-fallback";
|
||||
|
||||
interface LoadedSupervisorConfig {
|
||||
config: OrchestratorConfig;
|
||||
source: SupervisorConfigSource;
|
||||
}
|
||||
|
||||
function isMissingGlobalConfigError(error: unknown): boolean {
|
||||
export interface ReconcileProjectSupervisorOptions {
|
||||
intervalMs?: number;
|
||||
/**
|
||||
* Resolved config path from the caller (typically `ao start`). When the
|
||||
* global config is missing, this is used as the explicit local-fallback
|
||||
* source. Without it the supervisor would fall back to a cwd-walk via
|
||||
* bare `loadConfig()`, which misses configs in `ao start <url>` /
|
||||
* `ao start <path>` first-run flows — there the resolved config can
|
||||
* live under the clone/target path while the daemon's cwd is somewhere
|
||||
* else. A bare cwd-walk in that case throws ConfigNotFoundError, which
|
||||
* `run()` silently swallows, leaving `running.projects` empty.
|
||||
*/
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export interface StartProjectSupervisorOptions {
|
||||
intervalMs?: number;
|
||||
/** See {@link ReconcileProjectSupervisorOptions.configPath}. */
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
function isMissingConfigError(error: unknown): boolean {
|
||||
if (error instanceof ConfigNotFoundError) return true;
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
|
|
@ -38,6 +64,29 @@ function isMissingGlobalConfigError(error: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/** Load the supervisor config: prefer the global registry, fall back to the
|
||||
* caller-resolved local config path (or cwd discovery when none provided).
|
||||
* Returns the source so callers can gate authoritative actions (like the
|
||||
* detach pass) on whether we're looking at the real registry. */
|
||||
function loadSupervisorConfig(configPath?: string): LoadedSupervisorConfig {
|
||||
const globalConfigPath = getGlobalConfigPath();
|
||||
try {
|
||||
return { config: loadConfig(globalConfigPath), source: "global" };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT" &&
|
||||
"path" in error &&
|
||||
error.path === globalConfigPath
|
||||
) {
|
||||
const config = configPath ? loadConfig(configPath) : loadConfig();
|
||||
return { config, source: "local-fallback" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function reportProjectSupervisorError(
|
||||
observer: ProjectObserver,
|
||||
projectId: string,
|
||||
|
|
@ -69,23 +118,31 @@ async function projectHasNonTerminalSession(
|
|||
export async function reconcileProjectSupervisor(
|
||||
options: ReconcileProjectSupervisorOptions = {},
|
||||
): Promise<void> {
|
||||
const config = loadConfig(getGlobalConfigPath());
|
||||
const { config, source } = loadSupervisorConfig(options.configPath);
|
||||
const observer = createProjectObserver(config, "project-supervisor");
|
||||
const configuredProjectIds = new Set(Object.keys(config.projects));
|
||||
const activeProjectIds = new Set(listLifecycleWorkers());
|
||||
|
||||
for (const projectId of activeProjectIds) {
|
||||
if (!configuredProjectIds.has(projectId)) {
|
||||
try {
|
||||
stopLifecycleWorker(projectId);
|
||||
await removeProjectFromRunning(projectId);
|
||||
} catch (error) {
|
||||
reportProjectSupervisorError(
|
||||
observer,
|
||||
projectId,
|
||||
"Failed to detach lifecycle worker for removed project",
|
||||
error,
|
||||
);
|
||||
// Only the authoritative global registry can declare a project "removed".
|
||||
// On a local fallback (e.g. global config was deleted while the daemon is
|
||||
// already supervising multiple projects) the loaded config likely doesn't
|
||||
// enumerate every supervised project — running the detach pass would kill
|
||||
// unrelated lifecycle workers. Pre-fallback behavior was a no-op on
|
||||
// missing global; preserve that property for the detach pass specifically.
|
||||
if (source === "global") {
|
||||
const activeProjectIds = new Set(listLifecycleWorkers());
|
||||
for (const projectId of activeProjectIds) {
|
||||
if (!configuredProjectIds.has(projectId)) {
|
||||
try {
|
||||
stopLifecycleWorker(projectId);
|
||||
await removeProjectFromRunning(projectId);
|
||||
} catch (error) {
|
||||
reportProjectSupervisorError(
|
||||
observer,
|
||||
projectId,
|
||||
"Failed to detach lifecycle worker for removed project",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,16 +174,19 @@ export async function reconcileProjectSupervisor(
|
|||
}
|
||||
|
||||
export async function startProjectSupervisor(
|
||||
intervalMs: number = DEFAULT_SUPERVISOR_INTERVAL_MS,
|
||||
options: StartProjectSupervisorOptions = {},
|
||||
): Promise<SupervisorHandle> {
|
||||
if (activeSupervisor) return activeSupervisor;
|
||||
|
||||
const intervalMs = options.intervalMs ?? DEFAULT_SUPERVISOR_INTERVAL_MS;
|
||||
const configPath = options.configPath;
|
||||
|
||||
let reconciling = false;
|
||||
let pending = false;
|
||||
let stopped = false;
|
||||
let waiters: Array<() => void> = [];
|
||||
|
||||
const run = async (options: { swallowErrors?: boolean } = {}): Promise<void> => {
|
||||
const run = async (runOptions: { swallowErrors?: boolean } = {}): Promise<void> => {
|
||||
if (stopped) return;
|
||||
if (reconciling) {
|
||||
pending = true;
|
||||
|
|
@ -140,10 +200,10 @@ export async function startProjectSupervisor(
|
|||
do {
|
||||
pending = false;
|
||||
try {
|
||||
await reconcileProjectSupervisor({ intervalMs });
|
||||
await reconcileProjectSupervisor({ intervalMs, configPath });
|
||||
} catch (error) {
|
||||
if (isMissingGlobalConfigError(error)) return;
|
||||
if (!options.swallowErrors) throw error;
|
||||
if (isMissingConfigError(error)) return;
|
||||
if (!runOptions.swallowErrors) throw error;
|
||||
// Best-effort background loop: transient config/state errors should not crash ao start.
|
||||
}
|
||||
} while (pending && !stopped);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
isRepoUrl,
|
||||
loadConfig,
|
||||
parseRepoUrl,
|
||||
recordActivityEvent,
|
||||
registerProjectInGlobalConfig,
|
||||
resolveCloneTarget,
|
||||
isRepoAlreadyCloned,
|
||||
|
|
@ -196,6 +197,18 @@ async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise<Resolv
|
|||
await deps.cloneRepo(parsed, targetDir, cwdDir);
|
||||
console.log(chalk.green(` Cloned to ${targetDir}`));
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_resolve_failed",
|
||||
level: "error",
|
||||
summary: `failed to clone ${parsed.ownerRepo}`,
|
||||
data: {
|
||||
ownerRepo: parsed.ownerRepo,
|
||||
targetDir,
|
||||
source: "url-global",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
|
|
@ -286,6 +299,18 @@ async function fromUrl(arg: string, deps: ResolveDeps, opts: ResolveOptions): Pr
|
|||
spinner.succeed(`Cloned to ${targetDir}`);
|
||||
} catch (err) {
|
||||
spinner.fail("Clone failed");
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_resolve_failed",
|
||||
level: "error",
|
||||
summary: `failed to clone ${parsed.ownerRepo}`,
|
||||
data: {
|
||||
ownerRepo: parsed.ownerRepo,
|
||||
targetDir,
|
||||
source: "url-local",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
|
|
@ -462,6 +487,13 @@ async function fromCwdOrId(
|
|||
// First run — auto-create config in cwd.
|
||||
config = await deps.autoCreateConfig(cwd());
|
||||
recovered = true;
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.config_recovered",
|
||||
level: "info",
|
||||
summary: `auto-created config in cwd (first-run)`,
|
||||
data: { recovery: "auto_create", cwd: cwd() },
|
||||
});
|
||||
} else {
|
||||
// A config file exists but failed to load — likely a flat local
|
||||
// config whose project isn't in the global registry yet. Recover
|
||||
|
|
@ -469,9 +501,29 @@ async function fromCwdOrId(
|
|||
const foundConfig = findConfigFile() ?? undefined;
|
||||
if (!foundConfig) throw err;
|
||||
const addedId = await deps.registerFlatConfig(foundConfig);
|
||||
if (!addedId) throw err;
|
||||
if (!addedId) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.config_recovery_failed",
|
||||
level: "error",
|
||||
summary: `registerFlatConfig returned null — recovery failed`,
|
||||
data: {
|
||||
configPath: foundConfig,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
config = loadConfig(foundConfig);
|
||||
recovered = true;
|
||||
recordActivityEvent({
|
||||
projectId: addedId,
|
||||
source: "cli",
|
||||
kind: "cli.config_recovered",
|
||||
level: "info",
|
||||
summary: `registered flat config into global config and retried load`,
|
||||
data: { recovery: "register_flat", configPath: foundConfig },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { dashboardUrl } from "./dashboard-url.js";
|
||||
|
||||
export function projectSessionUrl(port: number, projectId: string, sessionId: string): string {
|
||||
return `http://localhost:${port}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`;
|
||||
return `${dashboardUrl(port)}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { atomicWriteFileSync } from "@aoagents/ao-core";
|
||||
import { atomicWriteFileSync, recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
export interface RunningState {
|
||||
pid: number;
|
||||
|
|
@ -134,6 +134,18 @@ async function acquireLock(
|
|||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.lock_timeout",
|
||||
level: "warn",
|
||||
summary: `lock acquisition timed out`,
|
||||
data: {
|
||||
resourceName,
|
||||
lockFile,
|
||||
timeoutMs,
|
||||
attempts: attempt,
|
||||
},
|
||||
});
|
||||
throw new Error(`Could not acquire ${resourceName} (${lockFile})`);
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +258,18 @@ export async function getRunning(): Promise<RunningState | null> {
|
|||
if (!isProcessAlive(state.pid)) {
|
||||
// Stale entry — process is dead, clean up
|
||||
writeState(null);
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stale_running_pruned",
|
||||
level: "warn",
|
||||
summary: `pruned stale running.json entry (dead pid ${state.pid})`,
|
||||
data: {
|
||||
pid: state.pid,
|
||||
port: state.port,
|
||||
startedAt: state.startedAt,
|
||||
projects: state.projects,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
isTerminalSession,
|
||||
loadConfig,
|
||||
markDaemonShutdownHandlerInstalled,
|
||||
recordActivityEvent,
|
||||
sweepDaemonChildren,
|
||||
} from "@aoagents/ao-core";
|
||||
import { stopBunTmpJanitor } from "./bun-tmp-janitor.js";
|
||||
|
|
@ -62,6 +63,15 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
|
||||
const exitCode = signal === "SIGINT" ? 130 : 0;
|
||||
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_signal",
|
||||
level: "info",
|
||||
summary: `received ${signal}, beginning graceful shutdown`,
|
||||
data: { signal, exitCode },
|
||||
});
|
||||
|
||||
try {
|
||||
stopProjectSupervisor();
|
||||
stopAllLifecycleWorkers();
|
||||
|
|
@ -69,7 +79,17 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
// Best-effort — never block shutdown on observability.
|
||||
}
|
||||
|
||||
const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_TIMEOUT_MS);
|
||||
const forceExit = setTimeout(() => {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_force_exit",
|
||||
level: "warn",
|
||||
summary: `force-exit after ${SHUTDOWN_TIMEOUT_MS}ms timeout`,
|
||||
data: { signal, timeoutMs: SHUTDOWN_TIMEOUT_MS, exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
forceExit.unref();
|
||||
|
||||
void (async () => {
|
||||
|
|
@ -86,8 +106,16 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
if (result.cleaned || result.alreadyTerminated) {
|
||||
killedSessionIds.push(session.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort per session
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId ?? ctx.projectId,
|
||||
sessionId: session.id,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_session_kill_failed",
|
||||
level: "warn",
|
||||
summary: `failed to kill session during shutdown`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,18 +135,49 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
for (const [pid, ids] of otherByProject) {
|
||||
otherProjects.push({ projectId: pid, sessionIds: ids });
|
||||
}
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: ctx.projectId,
|
||||
sessionIds: targetIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
try {
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: ctx.projectId,
|
||||
sessionIds: targetIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_write_failed",
|
||||
level: "error",
|
||||
summary: `failed to write last-stop state during shutdown`,
|
||||
data: {
|
||||
targetSessionCount: targetIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sweepDaemonChildren({ ownerPid: process.pid });
|
||||
await unregister();
|
||||
} catch {
|
||||
// Best-effort — always exit even if cleanup fails
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_completed",
|
||||
level: "info",
|
||||
summary: `clean shutdown completed`,
|
||||
data: { signal, killedSessionCount: killedSessionIds.length, exitCode },
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_failed",
|
||||
level: "error",
|
||||
summary: `shutdown body threw before cleanup completed`,
|
||||
data: { signal, errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
try {
|
||||
// Await any in-flight sweep so shutdown does not exit while
|
||||
|
|
|
|||
|
|
@ -0,0 +1,628 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const SLACK_APPS_URL = "https://api.slack.com/apps";
|
||||
const DEFAULT_USERNAME = "Agent Orchestrator";
|
||||
const SETUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface SlackSetupOptions {
|
||||
webhookUrl?: string;
|
||||
channel?: string;
|
||||
username?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedSlackSetup {
|
||||
webhookUrl: string;
|
||||
channel?: string;
|
||||
username: string;
|
||||
shouldSendTest: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class SlackSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SlackSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function validateSlackWebhookUrl(webhookUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(webhookUrl);
|
||||
} catch {
|
||||
throw new SlackSetupError("Slack webhook URL is invalid.");
|
||||
}
|
||||
|
||||
const validHost =
|
||||
parsed.hostname === "hooks.slack.com" || parsed.hostname === "hooks.slack-gov.com";
|
||||
if (parsed.protocol !== "https:" || !validHost || !parsed.pathname.startsWith("/services/")) {
|
||||
throw new SlackSetupError(
|
||||
"Slack webhook URL must look like https://hooks.slack.com/services/...",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new SlackSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingSlack(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["slack"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function buildSetupPayload(resolved: ResolvedSlackSetup): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: resolved.username,
|
||||
text: "AO Slack notifications are ready.",
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "AO Slack notifications are ready",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "This channel is now configured to receive AO notifications.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (resolved.channel) payload["channel"] = resolved.channel;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function sendSetupProbe(resolved: ResolvedSlackSetup): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(resolved.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildSetupPayload(resolved)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new SlackSetupError(
|
||||
`Slack setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SlackSetupError) throw error;
|
||||
throw new SlackSetupError(`Slack setup test failed: ${formatFetchError(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingSlack(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "slack" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new SlackSetupError(
|
||||
`notifiers.slack already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.slack already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing Slack notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function printManualWebhookInstructions(): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("Create a Slack incoming webhook"));
|
||||
console.log(` 1. Open ${SLACK_APPS_URL}`);
|
||||
console.log(" 2. Create a new app, or select an existing app.");
|
||||
console.log(" 3. Open Incoming Webhooks and activate them.");
|
||||
console.log(" 4. Click Add New Webhook to Workspace.");
|
||||
console.log(" 5. Pick the channel AO should post to and authorize.");
|
||||
console.log(" 6. Copy the generated webhook URL and paste it here.");
|
||||
console.log(
|
||||
chalk.dim("For private channels, the installing Slack user must already be in the channel."),
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function explainChannelBinding(): void {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"Slack incoming webhook URLs are bound to the channel selected during Slack authorization. To change channels, create a webhook for that channel.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SlackSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function promptSlackWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const webhookUrlInput = await clack.text({
|
||||
message: "Slack incoming webhook URL:",
|
||||
placeholder: "https://hooks.slack.com/services/...",
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "Slack webhook URL is required";
|
||||
try {
|
||||
validateSlackWebhookUrl(String(value));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(webhookUrlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return String(webhookUrlInput);
|
||||
}
|
||||
|
||||
async function promptAfterWebhookInstructions(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
printManualWebhookInstructions();
|
||||
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "After creating the Slack webhook, what do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste webhook URL",
|
||||
hint: "Continue setup",
|
||||
},
|
||||
{
|
||||
value: "show-steps",
|
||||
label: "Show steps again",
|
||||
hint: "Reprint the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "show-steps") {
|
||||
printManualWebhookInstructions();
|
||||
continue;
|
||||
}
|
||||
if (next === "enter-url") {
|
||||
return promptSlackWebhookUrl(clack, initialValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptChangeWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "How do you want to change the Slack webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste new webhook URL",
|
||||
hint: "Use a different Slack incoming webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "enter-url") {
|
||||
return promptSlackWebhookUrl(clack, existingWebhookUrl);
|
||||
}
|
||||
if (next === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: SlackSetupOptions,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedWebhookUrl = stringValue(opts.webhookUrl);
|
||||
if (providedWebhookUrl) return providedWebhookUrl;
|
||||
|
||||
while (true) {
|
||||
const source = existingWebhookUrl
|
||||
? await clack.select({
|
||||
message: "Slack notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing webhook URL",
|
||||
hint: "Keep sending to the currently configured Slack channel",
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Change webhook URL",
|
||||
hint: "Paste a different Slack incoming webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
})
|
||||
: await clack.select({
|
||||
message: "Do you already have a Slack incoming webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "have-url",
|
||||
label: "Yes, I have the URL",
|
||||
hint: "Paste the existing Slack webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "No, show me how to create one",
|
||||
hint: "AO will print the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing" && existingWebhookUrl) {
|
||||
return existingWebhookUrl;
|
||||
}
|
||||
|
||||
if (source === "change-url") {
|
||||
const result = await promptChangeWebhookUrl(clack, existingWebhookUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (source === "have-url") {
|
||||
return promptSlackWebhookUrl(clack, undefined);
|
||||
}
|
||||
|
||||
if (source === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: SlackSetupOptions,
|
||||
existingSlack: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedSlackSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingWebhookUrl = stringValue(existingSlack["webhookUrl"]);
|
||||
const optionRoutingPreset = resolveSlackRoutingPreset(opts.routingPreset);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup slack ")));
|
||||
explainChannelBinding();
|
||||
|
||||
while (true) {
|
||||
const resolvedWebhookUrl = await resolveInteractiveWebhookUrl(clack, opts, existingWebhookUrl);
|
||||
|
||||
const channelInput = await clack.text({
|
||||
message: "Channel name (optional; must match the channel selected when creating the webhook):",
|
||||
placeholder: "#agents",
|
||||
initialValue: stringValue(opts.channel) ?? stringValue(existingSlack["channel"]),
|
||||
});
|
||||
|
||||
if (clack.isCancel(channelInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const usernameInput = await clack.text({
|
||||
message: "Display name (optional):",
|
||||
placeholder: DEFAULT_USERNAME,
|
||||
initialValue:
|
||||
stringValue(opts.username) ?? stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME,
|
||||
});
|
||||
|
||||
if (clack.isCancel(usernameInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "slack", "Slack", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return buildResolvedSetup(
|
||||
resolvedWebhookUrl,
|
||||
stringValue(channelInput),
|
||||
stringValue(usernameInput),
|
||||
routingSelection === "preserve" ? undefined : routingSelection,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: SlackSetupOptions,
|
||||
existingSlack: Record<string, unknown>,
|
||||
): ResolvedSlackSetup {
|
||||
const webhookUrl =
|
||||
stringValue(opts.webhookUrl) ??
|
||||
(opts.refresh ? stringValue(existingSlack["webhookUrl"]) : undefined);
|
||||
if (!webhookUrl) {
|
||||
throw new SlackSetupError(
|
||||
"Slack webhook URL is required. Pass --webhook-url, or run `ao setup slack --refresh` with an existing Slack config.",
|
||||
);
|
||||
}
|
||||
|
||||
const channel = stringValue(opts.channel) ?? stringValue(existingSlack["channel"]);
|
||||
const username =
|
||||
stringValue(opts.username) ?? stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME;
|
||||
const routingPreset = resolveSlackRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all");
|
||||
return buildResolvedSetup(webhookUrl, channel, username, routingPreset, opts);
|
||||
}
|
||||
|
||||
function buildResolvedSetup(
|
||||
webhookUrl: string,
|
||||
channel: string | undefined,
|
||||
username: string | undefined,
|
||||
routingPreset: NotifierRoutingPreset | undefined,
|
||||
opts: SlackSetupOptions,
|
||||
): ResolvedSlackSetup {
|
||||
const normalizedWebhookUrl = webhookUrl.trim();
|
||||
validateSlackWebhookUrl(normalizedWebhookUrl);
|
||||
return {
|
||||
webhookUrl: normalizedWebhookUrl,
|
||||
channel,
|
||||
username: username ?? DEFAULT_USERNAME,
|
||||
shouldSendTest: opts.test !== false,
|
||||
routingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlackRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "Slack") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new SlackSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function writeSlackConfig(configPath: string, resolved: ResolvedSlackSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existingSlack = isRecord(notifiers["slack"]) ? notifiers["slack"] : {};
|
||||
const slackConfig: Record<string, unknown> = {
|
||||
...existingSlack,
|
||||
plugin: "slack",
|
||||
webhookUrl: resolved.webhookUrl,
|
||||
username: resolved.username,
|
||||
};
|
||||
if (resolved.channel) slackConfig["channel"] = resolved.channel;
|
||||
else delete slackConfig["channel"];
|
||||
notifiers["slack"] = slackConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "slack", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
function webhookUrlStatus(webhookUrl: string | undefined): string {
|
||||
if (!webhookUrl) return "not configured";
|
||||
try {
|
||||
return `configured (${new URL(webhookUrl).hostname})`;
|
||||
} catch {
|
||||
return "configured";
|
||||
}
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingSlack = getExistingSlack(context.rawConfig);
|
||||
const plugin = stringValue(existingSlack["plugin"]);
|
||||
const webhookUrl = stringValue(existingSlack["webhookUrl"]);
|
||||
const channel = stringValue(existingSlack["channel"]);
|
||||
const username = stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME;
|
||||
|
||||
console.log(chalk.bold("Slack notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Webhook URL: ${webhookUrlStatus(webhookUrl)}`);
|
||||
console.log(` Channel: ${channel ?? chalk.dim("webhook default")}`);
|
||||
console.log(` Username: ${username}`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "slack").label}`);
|
||||
|
||||
if (plugin !== "slack" || !webhookUrl) return;
|
||||
|
||||
try {
|
||||
await sendSetupProbe(buildResolvedSetup(webhookUrl, channel, username, undefined, { test: true }));
|
||||
console.log(chalk.green(" Probe: PASS"));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSlackSetupAction(opts: SlackSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingSlack = getExistingSlack(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingSlack(
|
||||
existingSlack["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingSlack)
|
||||
: await resolveInteractiveSetup(opts, existingSlack, context.rawConfig);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
await sendSetupProbe(resolved);
|
||||
console.log(chalk.green("✓ Slack setup test passed"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped Slack setup test."));
|
||||
}
|
||||
|
||||
writeSlackConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Slack setup complete!")} AO will send notifications through the configured Slack webhook.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to slack --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Slack setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to slack --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { promisify } from "node:util";
|
|||
import {
|
||||
getInstalledAoVersion,
|
||||
getUpdateCheckCachePath,
|
||||
isVersionOutdatedForChannel as coreIsVersionOutdatedForChannel,
|
||||
isVersionOutdated as coreIsVersionOutdated,
|
||||
loadGlobalConfig,
|
||||
type UpdateChannel,
|
||||
|
|
@ -181,8 +182,7 @@ export function classifyInstallPath(resolvedPath: string): InstallMethod {
|
|||
if (isPnpmGlobal) return "pnpm-global";
|
||||
|
||||
const isNpmGlobal =
|
||||
resolvedPath.includes("/lib/node_modules/") ||
|
||||
resolvedPath.includes("\\lib\\node_modules\\");
|
||||
resolvedPath.includes("/lib/node_modules/") || resolvedPath.includes("\\lib\\node_modules\\");
|
||||
if (isNpmGlobal) return "npm-global";
|
||||
|
||||
return "unknown";
|
||||
|
|
@ -256,10 +256,7 @@ export function getGitUpdateRef(): string {
|
|||
* a notice so the user runs it themselves — auto-running `npm install -g`
|
||||
* inside a brew prefix overwrites brew's symlinks.
|
||||
*/
|
||||
export function getUpdateCommand(
|
||||
method: InstallMethod,
|
||||
channel: UpdateChannel = "stable",
|
||||
): string {
|
||||
export function getUpdateCommand(method: InstallMethod, channel: UpdateChannel = "stable"): string {
|
||||
// "manual" channel maps to "stable" for the install command — the channel
|
||||
// affects when we check, not which tag manual installers should pick.
|
||||
const tag = channel === "nightly" ? "nightly" : "latest";
|
||||
|
|
@ -284,6 +281,14 @@ export function isManualOnlyInstall(method: InstallMethod): boolean {
|
|||
return method === "homebrew";
|
||||
}
|
||||
|
||||
export function isOutdatedForChannel(
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
channel: UpdateChannel,
|
||||
): boolean {
|
||||
return coreIsVersionOutdatedForChannel(currentVersion, latestVersion, channel);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -493,7 +498,7 @@ export async function checkForUpdate(opts?: {
|
|||
isOutdated:
|
||||
cached.installMethod === "git"
|
||||
? cached.isOutdated === true
|
||||
: isVersionOutdated(currentVersion, cached.latestVersion),
|
||||
: isOutdatedForChannel(currentVersion, cached.latestVersion, channel),
|
||||
installMethod,
|
||||
recommendedCommand,
|
||||
checkedAt: cached.checkedAt,
|
||||
|
|
@ -540,14 +545,16 @@ export async function checkForUpdate(opts?: {
|
|||
currentVersionAtCheck: currentVersion,
|
||||
installMethod,
|
||||
channel,
|
||||
isOutdated: isVersionOutdated(currentVersion, latestVersion),
|
||||
isOutdated: isOutdatedForChannel(currentVersion, latestVersion, channel),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isOutdated: latestVersion ? isVersionOutdated(currentVersion, latestVersion) : false,
|
||||
isOutdated: latestVersion
|
||||
? isOutdatedForChannel(currentVersion, latestVersion, channel)
|
||||
: false,
|
||||
installMethod,
|
||||
recommendedCommand,
|
||||
checkedAt: latestVersion ? now : null,
|
||||
|
|
@ -585,7 +592,7 @@ export function maybeShowUpdateNotice(): void {
|
|||
const isOutdated =
|
||||
installMethod === "git"
|
||||
? cached.isOutdated === true
|
||||
: isVersionOutdated(currentVersion, cached.latestVersion);
|
||||
: isOutdatedForChannel(currentVersion, cached.latestVersion, channel);
|
||||
if (!isOutdated) return;
|
||||
|
||||
const channelSuffix = channel === "nightly" ? " (nightly)" : "";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,536 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const DEFAULT_RETRIES = 2;
|
||||
const DEFAULT_RETRY_DELAY_MS = 1000;
|
||||
const SETUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface WebhookSetupOptions {
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface WebhookConfig {
|
||||
plugin: "webhook";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedWebhookSetup {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
shouldSendTest: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class WebhookSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WebhookSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function validateWebhookUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new WebhookSetupError("Webhook URL is invalid.");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new WebhookSetupError("Webhook URL must start with http:// or https://.");
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new WebhookSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingWebhook(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["webhook"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function getBearerToken(headers: unknown): string | undefined {
|
||||
if (!isRecord(headers)) return undefined;
|
||||
const authorization = stringValue(headers["Authorization"] ?? headers["authorization"]);
|
||||
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function buildSetupPayload(): Record<string, unknown> {
|
||||
return {
|
||||
type: "notification",
|
||||
event: {
|
||||
id: `webhook-setup-${Date.now()}`,
|
||||
type: "setup.webhook",
|
||||
priority: "info",
|
||||
sessionId: "setup",
|
||||
projectId: "ao",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "AO webhook notifications are ready.",
|
||||
data: {
|
||||
source: "ao-setup-webhook",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendSetupProbe(resolved: ResolvedWebhookSetup): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(resolved.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(resolved.headers ?? {}),
|
||||
},
|
||||
body: JSON.stringify(buildSetupPayload()),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new WebhookSetupError(
|
||||
`Webhook setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookSetupError) throw error;
|
||||
throw new WebhookSetupError(`Webhook setup test failed: ${formatFetchError(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingWebhook(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "webhook" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new WebhookSetupError(
|
||||
`notifiers.webhook already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.webhook already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing webhook notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new WebhookSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function promptWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const urlInput = await clack.text({
|
||||
message: "Webhook URL:",
|
||||
placeholder: "https://example.com/ao-events",
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "URL is required";
|
||||
try {
|
||||
validateWebhookUrl(String(value));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(urlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return String(urlInput);
|
||||
}
|
||||
|
||||
async function promptChangeWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
existingUrl: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
const next = await clack.select({
|
||||
message: "How do you want to configure the webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Add new webhook URL",
|
||||
hint: "Paste a different HTTP endpoint",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
return promptWebhookUrl(clack, existingUrl);
|
||||
}
|
||||
|
||||
async function resolveInteractiveUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: WebhookSetupOptions,
|
||||
existingUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedUrl = stringValue(opts.url);
|
||||
if (providedUrl) return providedUrl;
|
||||
|
||||
while (true) {
|
||||
if (!existingUrl) {
|
||||
return promptWebhookUrl(clack, undefined);
|
||||
}
|
||||
|
||||
const source = await clack.select({
|
||||
message: "Webhook notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing webhook URL",
|
||||
hint: "Keep sending to the currently configured endpoint",
|
||||
},
|
||||
{
|
||||
value: "add-new",
|
||||
label: "Add new webhook URL",
|
||||
hint: "Paste a different HTTP endpoint",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing") {
|
||||
return existingUrl;
|
||||
}
|
||||
|
||||
if (source === "add-new") {
|
||||
const result = await promptChangeWebhookUrl(clack, existingUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: WebhookSetupOptions,
|
||||
existingWebhook: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedWebhookSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingUrl = stringValue(existingWebhook["url"]);
|
||||
const existingToken = getBearerToken(existingWebhook["headers"]);
|
||||
const optionRoutingPreset = resolveWebhookRoutingPreset(opts.routingPreset);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup webhook ")));
|
||||
|
||||
while (true) {
|
||||
const resolvedUrl = await resolveInteractiveUrl(clack, opts, existingUrl);
|
||||
|
||||
let authToken = stringValue(opts.authToken);
|
||||
if (!authToken && existingToken) {
|
||||
const keepExisting = await clack.confirm({
|
||||
message: "Keep the existing Authorization bearer token?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (clack.isCancel(keepExisting)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (keepExisting) authToken = existingToken;
|
||||
}
|
||||
|
||||
if (!authToken) {
|
||||
const tokenInput = await clack.password({
|
||||
message: "Auth token (optional; leave blank for none):",
|
||||
});
|
||||
|
||||
if (clack.isCancel(tokenInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
authToken = stringValue(tokenInput);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "webhook", "webhook", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
|
||||
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
|
||||
|
||||
return buildResolvedSetup(
|
||||
resolvedUrl,
|
||||
authToken,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
routingSelection === "preserve" ? undefined : routingSelection,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: WebhookSetupOptions,
|
||||
existingWebhook: Record<string, unknown>,
|
||||
): ResolvedWebhookSetup {
|
||||
const url =
|
||||
stringValue(opts.url) ?? (opts.refresh ? stringValue(existingWebhook["url"]) : undefined);
|
||||
if (!url) {
|
||||
throw new WebhookSetupError(
|
||||
"Webhook URL is required. Pass --url, or run `ao setup webhook --refresh` with an existing webhook config.",
|
||||
);
|
||||
}
|
||||
|
||||
const authToken = stringValue(opts.authToken) ?? getBearerToken(existingWebhook["headers"]);
|
||||
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
|
||||
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
|
||||
const routingPreset =
|
||||
resolveWebhookRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all");
|
||||
|
||||
return buildResolvedSetup(url, authToken, retries, retryDelayMs, routingPreset, opts);
|
||||
}
|
||||
|
||||
function buildResolvedSetup(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
retries: number,
|
||||
retryDelayMs: number,
|
||||
routingPreset: NotifierRoutingPreset | undefined,
|
||||
opts: WebhookSetupOptions,
|
||||
): ResolvedWebhookSetup {
|
||||
const normalizedUrl = url.trim();
|
||||
validateWebhookUrl(normalizedUrl);
|
||||
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined;
|
||||
return {
|
||||
url: normalizedUrl,
|
||||
headers,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
shouldSendTest: opts.test !== false,
|
||||
routingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWebhookRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "webhook") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new WebhookSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function toWebhookConfig(resolved: ResolvedWebhookSetup): WebhookConfig {
|
||||
return {
|
||||
plugin: "webhook",
|
||||
url: resolved.url,
|
||||
...(resolved.headers ? { headers: resolved.headers } : {}),
|
||||
retries: resolved.retries,
|
||||
retryDelayMs: resolved.retryDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
function writeWebhookConfig(configPath: string, resolved: ResolvedWebhookSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
notifiers["webhook"] = toWebhookConfig(resolved);
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "webhook", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingWebhook = getExistingWebhook(context.rawConfig);
|
||||
const plugin = stringValue(existingWebhook["plugin"]);
|
||||
const url = stringValue(existingWebhook["url"]);
|
||||
const hasAuth = Boolean(getBearerToken(existingWebhook["headers"]));
|
||||
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
|
||||
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
|
||||
|
||||
console.log(chalk.bold("Webhook notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` URL: ${url ?? chalk.dim("not configured")}`);
|
||||
console.log(` Auth: ${hasAuth ? "Authorization bearer token configured" : "none"}`);
|
||||
console.log(` Retries: ${retries}`);
|
||||
console.log(` Retry delay: ${retryDelayMs}ms`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "webhook").label}`);
|
||||
|
||||
if (plugin !== "webhook" || !url) return;
|
||||
|
||||
try {
|
||||
await sendSetupProbe(
|
||||
buildResolvedSetup(
|
||||
url,
|
||||
getBearerToken(existingWebhook["headers"]),
|
||||
retries,
|
||||
retryDelayMs,
|
||||
undefined,
|
||||
{ test: true },
|
||||
),
|
||||
);
|
||||
console.log(chalk.green(" Probe: PASS"));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWebhookSetupAction(opts: WebhookSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingWebhook = getExistingWebhook(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingWebhook(
|
||||
existingWebhook["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingWebhook)
|
||||
: await resolveInteractiveSetup(opts, existingWebhook, context.rawConfig);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
await sendSetupProbe(resolved);
|
||||
console.log(chalk.green("✓ Webhook setup test passed"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped webhook setup test."));
|
||||
}
|
||||
|
||||
writeWebhookConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Webhook setup complete!")} AO will send notifications to ${resolved.url}.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to webhook --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Webhook setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to webhook --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { registerSession } from "./commands/session.js";
|
|||
import { registerSend } from "./commands/send.js";
|
||||
import { registerAcknowledge, registerReport } from "./commands/report.js";
|
||||
import { registerReviewCheck } from "./commands/review-check.js";
|
||||
import { registerReview } from "./commands/review.js";
|
||||
import { registerDashboard } from "./commands/dashboard.js";
|
||||
import { registerOpen } from "./commands/open.js";
|
||||
import { registerStart, registerStop } from "./commands/start.js";
|
||||
|
|
@ -13,6 +14,7 @@ import { registerDoctor } from "./commands/doctor.js";
|
|||
import { registerUpdate } from "./commands/update.js";
|
||||
import { registerSetup } from "./commands/setup.js";
|
||||
import { registerPlugin } from "./commands/plugin.js";
|
||||
import { registerNotify } from "./commands/notify.js";
|
||||
import { registerProjectCommand } from "./commands/project.js";
|
||||
import { registerMigrateStorage } from "./commands/migrate-storage.js";
|
||||
import { registerCompletion } from "./commands/completion.js";
|
||||
|
|
@ -39,6 +41,7 @@ export function createProgram(): Command {
|
|||
registerAcknowledge(program);
|
||||
registerReport(program);
|
||||
registerReviewCheck(program);
|
||||
registerReview(program);
|
||||
registerDashboard(program);
|
||||
registerOpen(program);
|
||||
registerVerify(program);
|
||||
|
|
@ -46,6 +49,7 @@ export function createProgram(): Command {
|
|||
registerUpdate(program);
|
||||
registerSetup(program);
|
||||
registerPlugin(program);
|
||||
registerNotify(program);
|
||||
registerProjectCommand(program);
|
||||
registerMigrateStorage(program);
|
||||
registerCompletion(program);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,84 @@
|
|||
# @aoagents/ao-core
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 73bed33: Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620).
|
||||
- `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature)
|
||||
- `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body)
|
||||
- `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body)
|
||||
- `api.webhook_failed` (error) — outer pipeline crash with `errorMessage`
|
||||
- `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle
|
||||
- `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only)
|
||||
- `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser")
|
||||
- `ui.terminal_protocol_error` (warn) — invalid mux client message
|
||||
- `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min
|
||||
|
||||
`api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window.
|
||||
|
||||
- 7d9b862: Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941).
|
||||
|
||||
Claude Code emits a lifecycle hook on every state transition that matters
|
||||
(`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`,
|
||||
…). Until now, AO ignored all but one of them and tried to infer the
|
||||
same information by regex-matching Claude's rendered terminal output —
|
||||
fragile by construction. Every Claude UI tweak (footer wording, status
|
||||
verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits
|
||||
patching the sharpest edges.
|
||||
|
||||
This release pivots:
|
||||
|
||||
**`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per
|
||||
workspace:
|
||||
- `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git
|
||||
side-effects (PR URL, branch, merge status).
|
||||
- `activity-updater` — new; registered on every hook that carries
|
||||
activity information (SessionStart, UserPromptSubmit, PreToolUse,
|
||||
PostToolUse, PostToolUseFailure, PostToolBatch, Notification,
|
||||
PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop,
|
||||
PreCompact, PostCompact). The script reads the JSON payload from
|
||||
stdin, maps `hook_event_name` to an activity state, and appends a
|
||||
JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`.
|
||||
|
||||
Notification is filtered by `notification_type` so `auth_success` /
|
||||
`elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket
|
||||
"Notification → waiting_input" would have regressed here).
|
||||
|
||||
The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of
|
||||
patterns + `agent.recordActivity`) is retired. `detectActivity` stays on
|
||||
the Agent interface for other agents but is now a stable `return "idle"`
|
||||
stub for Claude — the JSONL-backed cascade is the only source of truth
|
||||
for active / ready / waiting_input / blocked.
|
||||
|
||||
**`@aoagents/ao-core`** extends `ActivityLogEntry.source` and
|
||||
`ActivitySignalSource` with a `"hook"` value so the new entries are
|
||||
parseable and their provenance is visible in telemetry. No downstream
|
||||
consumer needs changes — the cascade has always read whatever source
|
||||
appeared in the JSONL, and the new tests assert hook-sourced entries
|
||||
flow through `checkActivityLogState` / `getActivityFallbackState`
|
||||
identically to terminal-sourced ones.
|
||||
|
||||
Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly
|
||||
one entry per event and preserves user-installed hooks alongside ours.
|
||||
Cross-platform: bash + Node (.cjs) variants behave identically against a
|
||||
shared 52-case scenario table.
|
||||
|
||||
- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
|
||||
- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.
|
||||
- 94981dc: feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
|
||||
|
||||
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a610601: Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"` → `blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903.
|
||||
|
||||
Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}` → `blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider).
|
||||
|
||||
- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup.
|
||||
- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ projects:
|
|||
globalConfigPath,
|
||||
[
|
||||
"port: 4000",
|
||||
"observability:",
|
||||
" logLevel: info",
|
||||
" stderr: true",
|
||||
"defaults:",
|
||||
" runtime: tmux",
|
||||
" agent: claude-code",
|
||||
|
|
@ -185,6 +188,7 @@ projects:
|
|||
);
|
||||
|
||||
const config = loadConfig(globalConfigPath);
|
||||
expect(config.observability).toEqual({ logLevel: "info", stderr: true });
|
||||
expect(Object.keys(config.projects)).toEqual(["clean-project"]);
|
||||
expect(config.projects["clean-project"]).toBeDefined();
|
||||
expect(config.degradedProjects["broken-project"]).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@aoagents/ao-core",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"description": "Core library — types, config, session manager, lifecycle manager, event bus",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { applyAgentReport } from "../agent-report.js";
|
||||
import { writeMetadata, writeCanonicalLifecycle } from "../metadata.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-agent-report-events-${randomUUID()}`);
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
sessionId = "ao-1";
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("activity events: agent-report", () => {
|
||||
it("emits api.agent_report.transition_rejected when the transition is invalid", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "terminated";
|
||||
writeMetadata(dataDir, sessionId, {
|
||||
worktree: "/tmp/worktree",
|
||||
branch: "feat/x",
|
||||
status: "terminated",
|
||||
project: "demo",
|
||||
});
|
||||
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
|
||||
|
||||
expect(() =>
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
now: new Date(),
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const rejected = calls.find(
|
||||
(c) => c.kind === "api.agent_report.transition_rejected",
|
||||
);
|
||||
expect(rejected).toBeDefined();
|
||||
expect(rejected?.sessionId).toBe(sessionId);
|
||||
expect(rejected?.source).toBe("api");
|
||||
});
|
||||
|
||||
it("does not emit transition_rejected on a valid transition", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = "2024-12-01T00:00:00.000Z";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
writeMetadata(dataDir, sessionId, {
|
||||
worktree: "/tmp/worktree",
|
||||
branch: "feat/x",
|
||||
status: "working",
|
||||
project: "demo",
|
||||
});
|
||||
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
|
||||
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "needs_input",
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "api.agent_report.transition_rejected")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import { saveGlobalConfig, type GlobalConfig } from "../global-config.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeGlobalConfig(projects: GlobalConfig["projects"] = {}): GlobalConfig {
|
||||
return {
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects,
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("activity events: config loading", () => {
|
||||
let tempRoot: string;
|
||||
let configPath: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalGlobalConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(
|
||||
tmpdir(),
|
||||
`ao-config-events-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
configPath = join(tempRoot, ".agent-orchestrator", "config.yaml");
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
originalHome = process.env["HOME"];
|
||||
originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["AO_GLOBAL_CONFIG"] = configPath;
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env["HOME"] = originalHome;
|
||||
if (originalGlobalConfig === undefined) {
|
||||
delete process.env["AO_GLOBAL_CONFIG"];
|
||||
} else {
|
||||
process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig;
|
||||
}
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits config.project_resolve_failed for unresolved projects without a specific config event", () => {
|
||||
const projectPath = join(tempRoot, "old-format");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectPath, "agent-orchestrator.yaml"),
|
||||
["projects:", " old-format:", " path: .", ""].join("\n"),
|
||||
);
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
"old-format": {
|
||||
projectId: "old-format",
|
||||
path: projectPath,
|
||||
displayName: "Old format",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "old-format",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_resolve_failed",
|
||||
projectId: "old-format",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit config.project_resolve_failed for malformed local yaml", () => {
|
||||
const projectPath = join(tempRoot, "malformed");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "tracker: [\n");
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
malformed: {
|
||||
projectId: "malformed",
|
||||
path: projectPath,
|
||||
displayName: "Malformed",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "malformed",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_malformed",
|
||||
projectId: "malformed",
|
||||
}),
|
||||
);
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit config.project_resolve_failed for healthy projects", () => {
|
||||
const projectPath = join(tempRoot, "clean");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectPath, "agent-orchestrator.yaml"),
|
||||
["agent: codex", "runtime: tmux", "workspace: worktree", ""].join("\n"),
|
||||
);
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
clean: {
|
||||
projectId: "clean",
|
||||
path: projectPath,
|
||||
displayName: "Clean",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "clean",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits config.project_malformed for unparseable local yaml", () => {
|
||||
const projectPath = join(tempRoot, "malformed");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "tracker: [\n");
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
malformed: {
|
||||
projectId: "malformed",
|
||||
path: projectPath,
|
||||
displayName: "Malformed",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "malformed",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_malformed",
|
||||
projectId: "malformed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits config.project_invalid for schema-invalid local yaml", () => {
|
||||
const projectPath = join(tempRoot, "invalid");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
// Valid YAML but fails LocalProjectConfigSchema (numeric agent isn't a string)
|
||||
writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "agent: 123\n");
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
invalid: {
|
||||
projectId: "invalid",
|
||||
path: projectPath,
|
||||
displayName: "Invalid",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "invalid",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_invalid",
|
||||
projectId: "invalid",
|
||||
}),
|
||||
);
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { migrateStorage } from "../migration/storage-v2.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
`ao-migration-events-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("activity events: storage migration", () => {
|
||||
let testDir: string;
|
||||
let aoBaseDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = createTempDir();
|
||||
aoBaseDir = join(testDir, ".agent-orchestrator");
|
||||
configPath = join(aoBaseDir, "config.yaml");
|
||||
mkdirSync(aoBaseDir, { recursive: true });
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
vi.mocked(execSync).mockReturnValue("");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits migration.completed when there is nothing to migrate", async () => {
|
||||
const result = await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(result.projects).toBe(0);
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const completed = calls.find((c) => c.kind === "migration.completed");
|
||||
expect(completed).toBeDefined();
|
||||
expect(completed?.source).toBe("migration");
|
||||
expect(completed?.level).toBe("info");
|
||||
expect(completed?.data).toMatchObject({
|
||||
projectsMigrated: 0,
|
||||
sessions: 0,
|
||||
worktrees: 0,
|
||||
projectErrors: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits migration.completed with totals when migration succeeds", async () => {
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
mkdirSync(join(hashDir, "worktrees", "ao-1"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(hashDir, "sessions", "ao-1"),
|
||||
[
|
||||
"project=myproject",
|
||||
"agent=claude-code",
|
||||
"status=working",
|
||||
"createdAt=2026-04-21T12:00:00.000Z",
|
||||
"branch=session/ao-1",
|
||||
`worktree=${join(hashDir, "worktrees", "ao-1")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"projects:",
|
||||
" myproject:",
|
||||
" path: /home/user/myproject",
|
||||
" storageKey: aaaaaa000000",
|
||||
" defaultBranch: main",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const completed = calls.find((c) => c.kind === "migration.completed");
|
||||
expect(completed).toBeDefined();
|
||||
const data = (completed?.data ?? {}) as Record<string, unknown>;
|
||||
expect(data["projectsMigrated"]).toBe(1);
|
||||
expect(data["projectErrors"]).toBe(0);
|
||||
});
|
||||
|
||||
it("emits migration.project_failed plus migration.completed when a project fails", async () => {
|
||||
// Force migrateProject to throw by pre-creating projects/badproj as a FILE
|
||||
// — mkdirSync with recursive:true tolerates existing directories but NOT existing
|
||||
// files at the same path, so it throws EEXIST when migrateProject tries to create
|
||||
// projects/badproj/sessions.
|
||||
const badDir = join(aoBaseDir, "bbbbbb000000-badproj");
|
||||
mkdirSync(join(badDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(badDir, "sessions", "ao-99"),
|
||||
[
|
||||
"project=badproj",
|
||||
"agent=claude-code",
|
||||
"createdAt=2026-04-21T12:00:00.000Z",
|
||||
"branch=session/ao-99",
|
||||
`worktree=${join(badDir, "worktrees", "ao-99")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// Pre-create projects/badproj as a regular file so mkdirSync inside migrateProject
|
||||
// raises ENOTDIR / EEXIST when it tries to create a subdirectory under it.
|
||||
mkdirSync(join(aoBaseDir, "projects"), { recursive: true });
|
||||
writeFileSync(join(aoBaseDir, "projects", "badproj"), "blocker");
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"projects:",
|
||||
" badproj:",
|
||||
" path: /home/user/badproj",
|
||||
" storageKey: bbbbbb000000",
|
||||
" defaultBranch: main",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const projectFailed = calls.filter((c) => c.kind === "migration.project_failed");
|
||||
const completed = calls.find((c) => c.kind === "migration.completed");
|
||||
|
||||
expect(projectFailed.length).toBeGreaterThanOrEqual(1);
|
||||
expect(projectFailed[0]?.projectId).toBe("badproj");
|
||||
expect(completed).toBeDefined();
|
||||
const completedData = (completed?.data ?? {}) as Record<string, unknown>;
|
||||
expect(completedData["projectErrors"]).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("emits migration.blocked when active sessions are detected", async () => {
|
||||
vi.mocked(execSync).mockReturnValue("ao-1\nabcdef012345-worker-7\nunrelated\n");
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
"projects:\n myproject:\n path: /tmp/x\n storageKey: aaaaaa000000\n defaultBranch: main\n",
|
||||
);
|
||||
|
||||
await expect(migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
log: () => {},
|
||||
})).rejects.toThrow(/Found 2 active AO tmux session/);
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const blocked = calls.find((c) => c.kind === "migration.blocked");
|
||||
expect(blocked).toBeDefined();
|
||||
expect(blocked?.source).toBe("migration");
|
||||
expect(blocked?.level).toBe("warn");
|
||||
expect(blocked?.summary).toBe("migration blocked by 2 active session(s)");
|
||||
expect(blocked?.data).toMatchObject({
|
||||
activeSessionCount: 2,
|
||||
sample: ["ao-1", "abcdef012345-worker-7"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit migration.blocked when force skips active-session detection", async () => {
|
||||
vi.mocked(execSync).mockReturnValue("ao-1\n");
|
||||
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(hashDir, "sessions", "ao-1"),
|
||||
[
|
||||
"project=myproject",
|
||||
"agent=claude-code",
|
||||
"createdAt=2026-04-21T12:00:00.000Z",
|
||||
"branch=session/ao-1",
|
||||
`worktree=${join(hashDir, "worktrees", "ao-1")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
mkdirSync(join(hashDir, "worktrees", "ao-1"), { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
"projects:\n myproject:\n path: /tmp/x\n storageKey: aaaaaa000000\n defaultBranch: main\n",
|
||||
);
|
||||
|
||||
// force: true skips the active-session check, so no migration.blocked event.
|
||||
await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "migration.blocked")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRegistry } from "../plugin-registry.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import type { OrchestratorConfig, PluginManifest, PluginModule } from "../types.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
function makePlugin(slot: PluginManifest["slot"], name: string): PluginModule {
|
||||
return {
|
||||
manifest: {
|
||||
name,
|
||||
slot,
|
||||
description: `Test ${slot} plugin: ${name}`,
|
||||
version: "0.0.1",
|
||||
},
|
||||
create: vi.fn(() => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeOrchestratorConfig(overrides?: Partial<OrchestratorConfig>): OrchestratorConfig {
|
||||
return {
|
||||
projects: {},
|
||||
...overrides,
|
||||
} as OrchestratorConfig;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
describe("activity events: plugin-registry", () => {
|
||||
it("emits plugin-registry.load_failed when a configured external plugin fails to import", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const config = makeOrchestratorConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "broken-plugin",
|
||||
source: "npm",
|
||||
package: "@example/broken",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await registry.loadFromConfig(config, async (pkg: string) => {
|
||||
// Built-ins all silently skip; external import throws
|
||||
if (pkg === "@example/broken") {
|
||||
throw new Error("module not found");
|
||||
}
|
||||
throw new Error(`builtin not installed: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const loadFailed = calls.find(
|
||||
(c) =>
|
||||
c.kind === "plugin-registry.load_failed" &&
|
||||
c.source === "plugin-registry" &&
|
||||
(c.data as Record<string, unknown> | undefined)?.["builtin"] === false,
|
||||
);
|
||||
expect(loadFailed).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits plugin-registry.specifier_failed when a plugin specifier cannot be resolved", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const config = makeOrchestratorConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "no-specifier",
|
||||
source: "local",
|
||||
// No path — resolvePluginSpecifier returns null
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await registry.loadFromConfig(config, async (pkg: string) => {
|
||||
throw new Error(`builtin not installed: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const specifierFailed = calls.find(
|
||||
(c) => c.kind === "plugin-registry.specifier_failed",
|
||||
);
|
||||
expect(specifierFailed).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits plugin-registry.load_failed when a built-in plugin's register() throws", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
// Make a plugin whose create() throws to force registration failure
|
||||
const fakeRuntime: PluginModule = {
|
||||
manifest: {
|
||||
name: "tmux",
|
||||
slot: "runtime",
|
||||
description: "throwing test plugin",
|
||||
version: "0.0.1",
|
||||
},
|
||||
create: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-runtime-tmux") return fakeRuntime;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const loadFailed = calls.find((c) => c.kind === "plugin-registry.load_failed");
|
||||
expect(loadFailed).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not emit any failure events when plugins load cleanly", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakePlugin = makePlugin("runtime", "tmux");
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-runtime-tmux") return fakePlugin;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const failures = calls.filter((c) =>
|
||||
typeof c.kind === "string" && c.kind.startsWith("plugin-registry."),
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -240,6 +240,15 @@ describe("readLastActivityEntry", () => {
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts entries with source: hook", async () => {
|
||||
await appendActivityEntry(tmpDir, "waiting_input", "hook", "PermissionRequest");
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.entry.state).toBe("waiting_input");
|
||||
expect(result!.entry.source).toBe("hook");
|
||||
expect(result!.entry.trigger).toBe("PermissionRequest");
|
||||
});
|
||||
|
||||
it("falls back to the previous complete line when a read races a truncated tail", async () => {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
const completeEntry: ActivityLogEntry = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -18,17 +18,25 @@ import {
|
|||
} from "../agent-report.js";
|
||||
import { writeMetadata, writeCanonicalLifecycle, readMetadataRaw } from "../metadata.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import type { CanonicalSessionLifecycle } from "../types.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`);
|
||||
tempRoot = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`);
|
||||
dataDir = join(tempRoot, "projects", "project-alpha", "sessions");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedWorkerSession(
|
||||
|
|
@ -433,6 +441,13 @@ describe("applyAgentReport", () => {
|
|||
sessionState: "terminated",
|
||||
},
|
||||
});
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-alpha",
|
||||
sessionId,
|
||||
kind: "api.agent_report.transition_rejected",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the session does not exist", () => {
|
||||
|
|
@ -442,6 +457,13 @@ describe("applyAgentReport", () => {
|
|||
now: new Date(),
|
||||
}),
|
||||
).toThrow(/not found/);
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-alpha",
|
||||
sessionId: "missing-session",
|
||||
kind: "api.agent_report.session_not_found",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// 260 atomic-write cycles are slow on Windows (rename + AV scan); bump the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,738 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
import { createCodeReviewStore, type CodeReviewStore } from "../code-review-store.js";
|
||||
import {
|
||||
buildCodexCodeReviewArgs,
|
||||
CodeReviewNoOpenFindingsError,
|
||||
executeCodeReviewRun,
|
||||
markOutdatedCodeReviewRunsForSession,
|
||||
parseReviewerOutput,
|
||||
prepareGitReviewerWorkspace,
|
||||
sendCodeReviewFindingsToAgent,
|
||||
triggerCodeReviewForSession,
|
||||
} from "../code-review-manager.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import {
|
||||
SessionNotFoundError,
|
||||
type OrchestratorConfig,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
} from "../types.js";
|
||||
|
||||
let storeDir: string;
|
||||
let store: CodeReviewStore;
|
||||
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: "/tmp/ao/agent-orchestrator.yaml",
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
app: {
|
||||
name: "App",
|
||||
path: "/tmp/app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
reactions: {},
|
||||
};
|
||||
|
||||
function makeSession(overrides: Partial<Session> & { id?: string } = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z"));
|
||||
lifecycle.session.state = "idle";
|
||||
lifecycle.session.reason = "awaiting_external_review";
|
||||
lifecycle.pr.state = "open";
|
||||
lifecycle.pr.reason = "review_pending";
|
||||
lifecycle.pr.number = 7;
|
||||
lifecycle.pr.url = "https://github.com/acme/app/pull/7";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/todos",
|
||||
issueId: null,
|
||||
pr: {
|
||||
number: 7,
|
||||
url: "https://github.com/acme/app/pull/7",
|
||||
title: "feat: todos",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/todos",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
workspacePath: "/tmp/app-worktree",
|
||||
runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date("2026-05-10T09:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSessionManager(
|
||||
session: Session | null,
|
||||
overrides: Partial<SessionManager> = {},
|
||||
): SessionManager {
|
||||
const manager: SessionManager = {
|
||||
get: async (sessionId: string) => (session?.id === sessionId ? session : null),
|
||||
list: async () => (session ? [session] : []),
|
||||
spawn: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
spawnOrchestrator: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
ensureOrchestrator: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
relaunchOrchestrator: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
restore: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
kill: async () => ({ cleaned: false, alreadyTerminated: false }),
|
||||
cleanup: async () => ({ killed: [], skipped: [], errors: [] }),
|
||||
send: async () => {},
|
||||
claimPR: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
};
|
||||
|
||||
return Object.assign(manager, overrides);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeDir = join(tmpdir(), `ao-test-code-review-manager-${randomUUID()}`);
|
||||
mkdirSync(storeDir, { recursive: true });
|
||||
store = createCodeReviewStore("app", { storeDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("triggerCodeReviewForSession", () => {
|
||||
it("creates a queued review run linked to the worker session", async () => {
|
||||
const session = makeSession();
|
||||
const run = await triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => "abc123",
|
||||
now: new Date("2026-05-10T11:00:00.000Z"),
|
||||
},
|
||||
{ sessionId: "app-1", requestedBy: "cli" },
|
||||
);
|
||||
|
||||
expect(run).toMatchObject({
|
||||
projectId: "app",
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
prNumber: 7,
|
||||
prUrl: "https://github.com/acme/app/pull/7",
|
||||
summary: "Review requested from CLI for app-1.",
|
||||
findingCount: 0,
|
||||
});
|
||||
expect(store.listRuns()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allocates reviewer ids from all prior project review runs", async () => {
|
||||
store.createRun({ linkedSessionId: "app-1", reviewerSessionId: "app-rev-1" });
|
||||
store.createRun({ linkedSessionId: "app-2", reviewerSessionId: "app-rev-7" });
|
||||
|
||||
const run = await triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession({ id: "app-3" })),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => undefined,
|
||||
},
|
||||
{ sessionId: "app-3", requestedBy: "web" },
|
||||
);
|
||||
|
||||
expect(run.reviewerSessionId).toBe("app-rev-8");
|
||||
});
|
||||
|
||||
it("allocates unique reviewer ids for concurrent review requests", async () => {
|
||||
let resolveGate: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resolveGate = resolve;
|
||||
});
|
||||
let shaLookups = 0;
|
||||
const options = {
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => {
|
||||
shaLookups++;
|
||||
if (shaLookups === 2) {
|
||||
resolveGate?.();
|
||||
}
|
||||
await gate;
|
||||
return "abc123";
|
||||
},
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
triggerCodeReviewForSession(options, { sessionId: "app-1", requestedBy: "web" }),
|
||||
triggerCodeReviewForSession(options, { sessionId: "app-1", requestedBy: "web" }),
|
||||
]);
|
||||
|
||||
expect(new Set([first.reviewerSessionId, second.reviewerSessionId]).size).toBe(2);
|
||||
expect(
|
||||
store
|
||||
.listRuns()
|
||||
.map((run) => run.reviewerSessionId)
|
||||
.sort(),
|
||||
).toEqual(["app-rev-1", "app-rev-2"]);
|
||||
});
|
||||
|
||||
it("marks previous review runs for older worker SHAs as outdated", async () => {
|
||||
const oldTriage = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const oldClean = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "clean",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const failed = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-3",
|
||||
status: "failed",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const otherWorker = store.createRun({
|
||||
linkedSessionId: "app-2",
|
||||
reviewerSessionId: "app-rev-4",
|
||||
status: "needs_triage",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
|
||||
const run = await triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => "new-sha",
|
||||
now: new Date("2026-05-10T12:00:00.000Z"),
|
||||
},
|
||||
{ sessionId: "app-1", requestedBy: "web" },
|
||||
);
|
||||
|
||||
expect(run.reviewerSessionId).toBe("app-rev-5");
|
||||
expect(store.getRun(oldTriage.id)?.status).toBe("outdated");
|
||||
expect(store.getRun(oldClean.id)?.status).toBe("outdated");
|
||||
expect(store.getRun(failed.id)?.status).toBe("failed");
|
||||
expect(store.getRun(otherWorker.id)?.status).toBe("needs_triage");
|
||||
});
|
||||
|
||||
it("marks stale review runs outdated when the worker HEAD changes", async () => {
|
||||
const oldWaiting = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "waiting_update",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const currentQueued = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "queued",
|
||||
targetSha: "new-sha",
|
||||
});
|
||||
const failed = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-3",
|
||||
status: "failed",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
|
||||
const updatedCount = await markOutdatedCodeReviewRunsForSession({
|
||||
store,
|
||||
session: makeSession(),
|
||||
resolveTargetSha: async () => "new-sha",
|
||||
now: new Date("2026-05-10T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(updatedCount).toBe(1);
|
||||
expect(store.getRun(oldWaiting.id)?.status).toBe("outdated");
|
||||
expect(store.getRun(currentQueued.id)?.status).toBe("queued");
|
||||
expect(store.getRun(failed.id)?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("rejects missing and orchestrator sessions", async () => {
|
||||
await expect(
|
||||
triggerCodeReviewForSession(
|
||||
{ config, sessionManager: makeSessionManager(null), storeFactory: () => store },
|
||||
{ sessionId: "app-404" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||
|
||||
await expect(
|
||||
triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(
|
||||
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
|
||||
),
|
||||
storeFactory: () => store,
|
||||
},
|
||||
{ sessionId: "app-orchestrator" },
|
||||
),
|
||||
).rejects.toThrow(/Cannot request code review for orchestrator session/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeCodeReviewRun", () => {
|
||||
it("runs a reviewer in an isolated workspace and persists findings", async () => {
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
});
|
||||
|
||||
const summary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async ({ run }) => `/tmp/reviews/${run.reviewerSessionId}`,
|
||||
runReviewer: async ({ workspacePath }) => ({
|
||||
summary: `Reviewed ${workspacePath}`,
|
||||
findings: [
|
||||
{
|
||||
severity: "error",
|
||||
title: "Broken save path",
|
||||
body: "The save handler drops failed writes.",
|
||||
filePath: "src/save.ts",
|
||||
startLine: 12,
|
||||
confidence: 0.9,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
status: "needs_triage",
|
||||
reviewerWorkspacePath: "/tmp/reviews/app-rev-1",
|
||||
findingCount: 1,
|
||||
openFindingCount: 1,
|
||||
summary: "Reviewed /tmp/reviews/app-rev-1",
|
||||
});
|
||||
expect(store.listFindings({ runId: run.id })[0]).toMatchObject({
|
||||
severity: "error",
|
||||
title: "Broken save path",
|
||||
filePath: "src/save.ts",
|
||||
startLine: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the project default branch when the session PR base branch is empty", async () => {
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
});
|
||||
let observedBaseRef: string | undefined;
|
||||
const session = makeSession({
|
||||
pr: {
|
||||
...makeSession().pr!,
|
||||
baseBranch: "",
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => "/tmp/reviews/app-rev-1",
|
||||
runReviewer: async ({ baseRef }) => {
|
||||
observedBaseRef = baseRef;
|
||||
return { findings: [] };
|
||||
},
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
|
||||
expect(observedBaseRef).toBe("main");
|
||||
expect(summary.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("allows only one concurrent execution to claim the same queued review run", async () => {
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
});
|
||||
let resolveSessionLookup: (() => void) | undefined;
|
||||
const sessionLookupGate = new Promise<void>((resolve) => {
|
||||
resolveSessionLookup = resolve;
|
||||
});
|
||||
let sessionLookups = 0;
|
||||
let prepareCalls = 0;
|
||||
const sessionManager = makeSessionManager(makeSession(), {
|
||||
get: async () => {
|
||||
sessionLookups++;
|
||||
if (sessionLookups === 2) {
|
||||
resolveSessionLookup?.();
|
||||
}
|
||||
await sessionLookupGate;
|
||||
return makeSession();
|
||||
},
|
||||
});
|
||||
|
||||
const first = executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => {
|
||||
prepareCalls++;
|
||||
return "/tmp/reviews/app-rev-1";
|
||||
},
|
||||
runReviewer: async () => ({ findings: [] }),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
while (sessionLookups === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const second = executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => {
|
||||
prepareCalls++;
|
||||
return "/tmp/reviews/app-rev-1";
|
||||
},
|
||||
runReviewer: async () => ({ findings: [] }),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
await Promise.resolve();
|
||||
resolveSessionLookup?.();
|
||||
|
||||
const results = await Promise.allSettled([first, second]);
|
||||
|
||||
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
||||
expect(prepareCalls).toBe(1);
|
||||
expect(store.getRun(run.id)?.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("marks clean reviews clean and records failed reviewer executions", async () => {
|
||||
const cleanRun = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
const cleanSummary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => "/tmp/reviews/app-rev-1",
|
||||
runReviewer: async () => ({ rawOutput: '{"findings":[]}' }),
|
||||
},
|
||||
{ projectId: "app", runId: cleanRun.id },
|
||||
);
|
||||
expect(cleanSummary.status).toBe("clean");
|
||||
expect(cleanSummary.findingCount).toBe(0);
|
||||
|
||||
const failedRun = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "queued",
|
||||
});
|
||||
const failedSummary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => "/tmp/reviews/app-rev-2",
|
||||
runReviewer: async () => {
|
||||
throw new Error("review command crashed");
|
||||
},
|
||||
},
|
||||
{ projectId: "app", runId: failedRun.id },
|
||||
);
|
||||
expect(failedSummary.status).toBe("failed");
|
||||
expect(failedSummary.terminationReason).toBe("review command crashed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendCodeReviewFindingsToAgent", () => {
|
||||
it("sends open review findings to the linked coding worker and marks them sent", async () => {
|
||||
const session = makeSession();
|
||||
const sentMessages: Array<{ sessionId: string; message: string }> = [];
|
||||
const run = store.createRun({
|
||||
linkedSessionId: session.id,
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
targetSha: "abc123",
|
||||
prNumber: 7,
|
||||
prUrl: "https://github.com/acme/app/pull/7",
|
||||
});
|
||||
const first = store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "error",
|
||||
title: "Broken save path",
|
||||
body: "The save handler drops failed writes.",
|
||||
filePath: "src/save.ts",
|
||||
startLine: 12,
|
||||
confidence: 0.9,
|
||||
});
|
||||
const second = store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "warning",
|
||||
title: "Missing retry",
|
||||
body: "The request fails permanently on transient network errors.",
|
||||
filePath: "src/api.ts",
|
||||
startLine: 4,
|
||||
endLine: 8,
|
||||
});
|
||||
const dismissed = store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "info",
|
||||
title: "Dismissed nit",
|
||||
body: "This should not be sent.",
|
||||
status: "dismissed",
|
||||
});
|
||||
|
||||
const result = await sendCodeReviewFindingsToAgent(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session, {
|
||||
send: async (sessionId, message) => {
|
||||
sentMessages.push({ sessionId, message });
|
||||
},
|
||||
}),
|
||||
storeFactory: () => store,
|
||||
now: () => new Date("2026-05-10T12:00:00.000Z"),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
expect(sentMessages[0]?.sessionId).toBe("app-1");
|
||||
expect(sentMessages[0]?.message).toContain("AO reviewer app-rev-1 found 2 open issues");
|
||||
expect(sentMessages[0]?.message).toContain("Review run:");
|
||||
expect(sentMessages[0]?.message).toContain("[error] Broken save path");
|
||||
expect(sentMessages[0]?.message).toContain("Location: src/save.ts:12");
|
||||
expect(sentMessages[0]?.message).toContain("[warning] Missing retry");
|
||||
expect(sentMessages[0]?.message).toContain("Location: src/api.ts:4-8");
|
||||
expect(sentMessages[0]?.message).not.toContain("Dismissed nit");
|
||||
expect(result).toMatchObject({
|
||||
sentFindingCount: 2,
|
||||
run: {
|
||||
status: "waiting_update",
|
||||
openFindingCount: 0,
|
||||
sentFindingCount: 2,
|
||||
dismissedFindingCount: 1,
|
||||
},
|
||||
});
|
||||
expect(store.getFinding(first.id)).toMatchObject({
|
||||
status: "sent_to_agent",
|
||||
sentToAgentAt: "2026-05-10T12:00:00.000Z",
|
||||
});
|
||||
expect(store.getFinding(second.id)?.status).toBe("sent_to_agent");
|
||||
expect(store.getFinding(dismissed.id)?.status).toBe("dismissed");
|
||||
});
|
||||
|
||||
it("does not send or mutate when there are no open findings", async () => {
|
||||
const session = makeSession();
|
||||
const run = store.createRun({
|
||||
linkedSessionId: session.id,
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "clean",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "warning",
|
||||
title: "Already sent",
|
||||
body: "Do not resend this.",
|
||||
status: "sent_to_agent",
|
||||
});
|
||||
const sentMessages: string[] = [];
|
||||
|
||||
await expect(
|
||||
sendCodeReviewFindingsToAgent(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session, {
|
||||
send: async (_sessionId, message) => {
|
||||
sentMessages.push(message);
|
||||
},
|
||||
}),
|
||||
storeFactory: () => store,
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
),
|
||||
).rejects.toBeInstanceOf(CodeReviewNoOpenFindingsError);
|
||||
|
||||
expect(sentMessages).toEqual([]);
|
||||
expect(store.getRun(run.id)?.status).toBe("clean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCodexCodeReview", () => {
|
||||
it("uses generic codex exec instead of the review subcommand base/prompt combination", () => {
|
||||
const args = buildCodexCodeReviewArgs("/tmp/review-output.json", "Return JSON only.");
|
||||
|
||||
expect(args).toEqual([
|
||||
"exec",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--output-last-message",
|
||||
"/tmp/review-output.json",
|
||||
"Return JSON only.",
|
||||
]);
|
||||
expect(args).not.toContain("review");
|
||||
expect(args).not.toContain("--base");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareGitReviewerWorkspace", () => {
|
||||
it("prunes stale git worktree metadata when the reviewer workspace directory is gone", async () => {
|
||||
const tmpHome = join(tmpdir(), `ao-test-review-worktree-${randomUUID()}`);
|
||||
const originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpHome;
|
||||
|
||||
try {
|
||||
const repoPath = join(tmpHome, "repo");
|
||||
mkdirSync(repoPath, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoPath });
|
||||
writeFileSync(join(repoPath, "README.md"), "# App\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoPath });
|
||||
execFileSync("git", ["commit", "-m", "initial"], {
|
||||
cwd: repoPath,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "AO Test",
|
||||
GIT_AUTHOR_EMAIL: "ao@example.com",
|
||||
GIT_COMMITTER_NAME: "AO Test",
|
||||
GIT_COMMITTER_EMAIL: "ao@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-stale",
|
||||
status: "queued",
|
||||
});
|
||||
const project = { ...config.projects.app!, path: repoPath };
|
||||
const workspaceRoot = join(
|
||||
tmpHome,
|
||||
".agent-orchestrator",
|
||||
"projects",
|
||||
"app",
|
||||
"code-reviews",
|
||||
"workspaces",
|
||||
);
|
||||
const workspacePath = join(workspaceRoot, run.reviewerSessionId);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", "--detach", workspacePath, "HEAD"], {
|
||||
cwd: repoPath,
|
||||
});
|
||||
rmSync(workspacePath, { recursive: true, force: true });
|
||||
|
||||
const preparedPath = await prepareGitReviewerWorkspace({
|
||||
projectId: "app",
|
||||
project,
|
||||
session: makeSession({ workspacePath: repoPath }),
|
||||
run,
|
||||
});
|
||||
|
||||
expect(preparedPath).toBe(workspacePath);
|
||||
expect(existsSync(workspacePath)).toBe(true);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReviewerOutput", () => {
|
||||
it("parses JSON findings and falls back to a single reviewer-output finding", () => {
|
||||
expect(
|
||||
parseReviewerOutput(
|
||||
JSON.stringify({
|
||||
findings: [{ severity: "warning", title: "Risk", body: "A concrete issue." }],
|
||||
}),
|
||||
),
|
||||
).toMatchObject([{ severity: "warning", title: "Risk", body: "A concrete issue." }]);
|
||||
expect(parseReviewerOutput("No findings.")).toEqual([]);
|
||||
expect(parseReviewerOutput("Unexpected reviewer text")).toMatchObject([
|
||||
{ severity: "warning", title: "Reviewer output", body: "Unexpected reviewer text" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not drop structured findings whose text mentions no findings", () => {
|
||||
expect(
|
||||
parseReviewerOutput(
|
||||
JSON.stringify({
|
||||
findings: [
|
||||
{
|
||||
severity: "warning",
|
||||
title: "No findings banner is stale",
|
||||
body: "The UI still says no findings even when the reviewer found one.",
|
||||
filePath: "src/review.ts",
|
||||
startLine: 42,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
severity: "warning",
|
||||
title: "No findings banner is stale",
|
||||
body: "The UI still says no findings even when the reviewer found one.",
|
||||
filePath: "src/review.ts",
|
||||
startLine: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createCodeReviewStore, type CodeReviewStore } from "../code-review-store.js";
|
||||
|
||||
let storeDir: string;
|
||||
let store: CodeReviewStore;
|
||||
|
||||
beforeEach(() => {
|
||||
storeDir = join(tmpdir(), `ao-test-code-review-store-${randomUUID()}`);
|
||||
mkdirSync(storeDir, { recursive: true });
|
||||
store = createCodeReviewStore("app", { storeDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("CodeReviewStore", () => {
|
||||
it("creates runs and lists summaries with finding counts", () => {
|
||||
const run = store.createRun(
|
||||
{
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
targetSha: "abc123",
|
||||
prNumber: 42,
|
||||
},
|
||||
new Date("2026-05-10T10:00:00.000Z"),
|
||||
);
|
||||
|
||||
store.createFinding(
|
||||
{
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "error",
|
||||
title: "Missing guard",
|
||||
body: "The handler reads a nullable value without checking it.",
|
||||
filePath: "src/auth.ts",
|
||||
startLine: 12,
|
||||
fingerprint: "auth-guard",
|
||||
},
|
||||
new Date("2026-05-10T10:01:00.000Z"),
|
||||
);
|
||||
const dismissed = store.createFinding(
|
||||
{
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "info",
|
||||
title: "Naming nit",
|
||||
body: "Consider a clearer name.",
|
||||
},
|
||||
new Date("2026-05-10T10:02:00.000Z"),
|
||||
);
|
||||
store.updateFinding(dismissed.id, { status: "dismissed", dismissedBy: "operator" });
|
||||
|
||||
const summaries = store.listRunSummaries({ linkedSessionId: "app-1" });
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
id: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
findingCount: 2,
|
||||
openFindingCount: 1,
|
||||
dismissedFindingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters runs and findings independently", () => {
|
||||
const first = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "clean",
|
||||
});
|
||||
const second = store.createRun({
|
||||
linkedSessionId: "app-2",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "failed",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: second.id,
|
||||
linkedSessionId: "app-2",
|
||||
severity: "warning",
|
||||
title: "Race condition",
|
||||
body: "This update can be lost.",
|
||||
});
|
||||
|
||||
expect(store.listRuns({ status: "clean" }).map((run) => run.id)).toEqual([first.id]);
|
||||
expect(store.listRuns({ linkedSessionId: "app-2" }).map((run) => run.id)).toEqual([second.id]);
|
||||
expect(store.listFindings({ linkedSessionId: "app-1" })).toEqual([]);
|
||||
expect(store.listFindings({ linkedSessionId: "app-2" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects path traversal ids", () => {
|
||||
expect(() => store.getRun("../bad")).toThrow(/Unsafe review run id/);
|
||||
expect(() => store.getFinding("../bad")).toThrow(/Unsafe review finding id/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1247,3 +1247,61 @@ describe("Config Validation - Power Config", () => {
|
|||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Validation - Observability Config", () => {
|
||||
const baseConfig = {
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("applies default observability config", () => {
|
||||
const config = validateConfig(baseConfig);
|
||||
|
||||
expect(config.observability).toEqual({
|
||||
logLevel: "warn",
|
||||
stderr: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts explicit observability settings", () => {
|
||||
const config = validateConfig({
|
||||
...baseConfig,
|
||||
observability: {
|
||||
logLevel: "info",
|
||||
stderr: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.observability).toEqual({
|
||||
logLevel: "info",
|
||||
stderr: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid observability settings", () => {
|
||||
expect(() =>
|
||||
validateConfig({
|
||||
...baseConfig,
|
||||
observability: {
|
||||
logLevel: "verbose",
|
||||
stderr: false,
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateConfig({
|
||||
...baseConfig,
|
||||
observability: {
|
||||
logLevel: "warn",
|
||||
stderr: "yes",
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OrchestratorEvent } from "../types.js";
|
||||
import { buildCIFailureNotificationData } from "../notification-data.js";
|
||||
import {
|
||||
appendDashboardNotificationRecord,
|
||||
createDashboardNotificationRecord,
|
||||
normalizeDashboardNotificationLimit,
|
||||
readDashboardNotificationsFromFile,
|
||||
writeDashboardNotificationsToFile,
|
||||
} from "../dashboard-notifications.js";
|
||||
|
||||
let tempDir: string | null = null;
|
||||
|
||||
function makeTempPath(): string {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "ao-dashboard-notifications-"));
|
||||
return join(tempDir, "dashboard-notifications.jsonl");
|
||||
}
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
sessionId: "app-1",
|
||||
projectId: "demo",
|
||||
timestamp: new Date("2026-05-13T10:00:00.000Z"),
|
||||
message: "CI is failing",
|
||||
data: buildCIFailureNotificationData({
|
||||
sessionId: "app-1",
|
||||
projectId: "demo",
|
||||
context: {
|
||||
pr: {
|
||||
number: 1,
|
||||
url: "https://github.com/acme/app/pull/1",
|
||||
title: "Fix dashboard notifications",
|
||||
branch: "fix/dashboard-notifications",
|
||||
baseBranch: "main",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
isDraft: false,
|
||||
},
|
||||
issueId: "AO-1",
|
||||
issueTitle: "Fix dashboard notifications",
|
||||
summary: "Fix dashboard notifications",
|
||||
branch: "fix/dashboard-notifications",
|
||||
},
|
||||
failedChecks: [{ name: "typecheck", status: "failed", conclusion: "FAILURE" }],
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
});
|
||||
|
||||
describe("dashboard notifications", () => {
|
||||
it("serializes events and actions into dashboard records", () => {
|
||||
const record = createDashboardNotificationRecord(
|
||||
makeEvent(),
|
||||
[{ label: "View PR", url: "https://github.com/acme/app/pull/1" }],
|
||||
new Date("2026-05-13T11:00:00.000Z"),
|
||||
);
|
||||
|
||||
expect(record.id).toBe("evt-1:2026-05-13T11:00:00.000Z");
|
||||
expect(record.event.timestamp).toBe("2026-05-13T10:00:00.000Z");
|
||||
expect(record.event.data).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
subject: { pr: { url: "https://github.com/acme/app/pull/1" } },
|
||||
ci: { status: "failing" },
|
||||
});
|
||||
expect(record.event.data.prUrl).toBeUndefined();
|
||||
expect(record.actions).toEqual([
|
||||
{ label: "View PR", url: "https://github.com/acme/app/pull/1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes and reads JSONL records", () => {
|
||||
const filePath = makeTempPath();
|
||||
const record = createDashboardNotificationRecord(makeEvent());
|
||||
|
||||
writeDashboardNotificationsToFile(filePath, [record], 50);
|
||||
|
||||
expect(readDashboardNotificationsFromFile(filePath)).toEqual([record]);
|
||||
expect(readFileSync(filePath, "utf-8")).toContain('"sessionId":"app-1"');
|
||||
});
|
||||
|
||||
it("retains only the latest limit records", () => {
|
||||
const filePath = makeTempPath();
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
appendDashboardNotificationRecord(
|
||||
filePath,
|
||||
createDashboardNotificationRecord(
|
||||
makeEvent({ id: `evt-${i}` }),
|
||||
undefined,
|
||||
new Date(`2026-05-13T11:00:0${i}.000Z`),
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
readDashboardNotificationsFromFile(filePath, 50).map((record) => record.event.id),
|
||||
).toEqual(["evt-3", "evt-4"]);
|
||||
});
|
||||
|
||||
it("skips malformed JSONL lines", () => {
|
||||
const filePath = makeTempPath();
|
||||
const record = createDashboardNotificationRecord(makeEvent());
|
||||
writeFileSync(filePath, `not-json\n${JSON.stringify(record)}\n{"id":"bad"}\n`, "utf-8");
|
||||
|
||||
expect(readDashboardNotificationsFromFile(filePath)).toEqual([record]);
|
||||
});
|
||||
|
||||
it("keeps legacy JSONL records readable without transforming their data", () => {
|
||||
const filePath = makeTempPath();
|
||||
const legacyRecord = {
|
||||
id: "evt-legacy:2026-05-13T11:00:00.000Z",
|
||||
receivedAt: "2026-05-13T11:00:00.000Z",
|
||||
event: {
|
||||
id: "evt-legacy",
|
||||
type: "ci.failing",
|
||||
priority: "warning",
|
||||
sessionId: "app-1",
|
||||
projectId: "demo",
|
||||
timestamp: "2026-05-13T10:00:00.000Z",
|
||||
message: "CI is failing",
|
||||
data: { schemaVersion: 2, prUrl: "https://github.com/acme/app/pull/1" },
|
||||
},
|
||||
};
|
||||
writeFileSync(filePath, `${JSON.stringify(legacyRecord)}\n`, "utf-8");
|
||||
|
||||
expect(readDashboardNotificationsFromFile(filePath)).toEqual([legacyRecord]);
|
||||
});
|
||||
|
||||
it("clamps invalid limits", () => {
|
||||
expect(normalizeDashboardNotificationLimit(undefined)).toBe(50);
|
||||
expect(normalizeDashboardNotificationLimit(0)).toBe(1);
|
||||
expect(normalizeDashboardNotificationLimit("1000")).toBe(500);
|
||||
expect(normalizeDashboardNotificationLimit("75")).toBe(75);
|
||||
});
|
||||
});
|
||||
|
|
@ -351,6 +351,57 @@ describe("global-config storage identity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves wrapped config defaults when repairing local behavior", () => {
|
||||
const repoPath = createRepo("wrapped-local-defaults", "https://github.com/OpenAI/demo.git");
|
||||
const projectId = registerProjectInGlobalConfig(
|
||||
"wrapped-local-defaults",
|
||||
"Wrapped Local Defaults",
|
||||
repoPath,
|
||||
{ defaultBranch: "main" },
|
||||
configPath,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repoPath, "agent-orchestrator.yaml"),
|
||||
[
|
||||
"defaults:",
|
||||
" agent: codex",
|
||||
" runtime: tmux",
|
||||
" workspace: worktree",
|
||||
" orchestrator:",
|
||||
" agent: codex",
|
||||
" worker:",
|
||||
" agent: opencode",
|
||||
"projects:",
|
||||
" wrapped-local-defaults:",
|
||||
` path: ${repoPath}`,
|
||||
" name: Wrapped Local Defaults",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({
|
||||
resolveError: expect.stringContaining("wrapped projects: format"),
|
||||
});
|
||||
|
||||
repairWrappedLocalProjectConfig(projectId, repoPath);
|
||||
|
||||
const repaired = parseYaml(readFileSync(join(repoPath, "agent-orchestrator.yaml"), "utf-8"));
|
||||
expect(repaired).toEqual({
|
||||
agent: "codex",
|
||||
runtime: "tmux",
|
||||
workspace: "worktree",
|
||||
orchestrator: { agent: "codex" },
|
||||
worker: { agent: "opencode" },
|
||||
});
|
||||
expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({
|
||||
agent: "codex",
|
||||
runtime: "tmux",
|
||||
workspace: "worktree",
|
||||
orchestrator: { agent: "codex" },
|
||||
worker: { agent: "opencode" },
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs wrapped local .yml configs without creating a .yaml sibling", () => {
|
||||
const repoPath = createRepo("wrapped-local-yml", "https://github.com/OpenAI/demo.git");
|
||||
const configPathYml = join(repoPath, "agent-orchestrator.yml");
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
* test that proves the lifecycle check completes successfully even if
|
||||
* recordActivityEvent itself throws (B2 invariant).
|
||||
*
|
||||
* Notifier instrumentation is intentionally omitted — the notifier subsystem
|
||||
* is undergoing larger work and AE evidence there is not currently useful.
|
||||
* Notification delivery instrumentation is covered in lifecycle-manager.test.ts
|
||||
* because it needs real observability snapshots in addition to activity events.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type * as ActivityEventsModule from "../activity-events.js";
|
||||
|
|
@ -76,6 +76,7 @@ function persistSession(
|
|||
branch: session.branch ?? "main",
|
||||
status: session.status,
|
||||
project: "my-app",
|
||||
agent: session.metadata["agent"] ?? "mock-agent",
|
||||
runtimeHandle: session.runtimeHandle ?? undefined,
|
||||
...metaOverrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ function setupCheck(
|
|||
branch: opts.session.branch ?? "main",
|
||||
status: opts.session.status,
|
||||
project: "my-app",
|
||||
agent: opts.session.metadata["agent"] ?? "mock-agent",
|
||||
runtimeHandle: opts.session.runtimeHandle ?? undefined,
|
||||
...opts.metaOverrides,
|
||||
};
|
||||
|
|
@ -225,6 +226,7 @@ function setupPollCheck(
|
|||
branch: opts.session.branch ?? "main",
|
||||
status: opts.session.status,
|
||||
project: "my-app",
|
||||
agent: opts.session.metadata["agent"] ?? "mock-agent",
|
||||
runtimeHandle: opts.session.runtimeHandle ?? undefined,
|
||||
...opts.metaOverrides,
|
||||
};
|
||||
|
|
@ -530,7 +532,7 @@ describe("check (single session)", () => {
|
|||
expect(lm.getStates().get("app-1")).toBe("detecting");
|
||||
});
|
||||
|
||||
it("uses worker-specific agent fallback when metadata does not persist an agent", async () => {
|
||||
it("uses persisted session agent even when worker config differs", async () => {
|
||||
const codexAgent: Agent = {
|
||||
...plugins.agent,
|
||||
name: "codex",
|
||||
|
|
@ -557,13 +559,13 @@ describe("check (single session)", () => {
|
|||
"my-app": {
|
||||
...config.projects["my-app"],
|
||||
agent: "mock-agent",
|
||||
worker: { agent: "codex" },
|
||||
worker: { agent: "mock-agent" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "working", metadata: {} }),
|
||||
session: makeSession({ status: "working", metadata: { agent: "codex" } }),
|
||||
registry: registryWithMultipleAgents,
|
||||
configOverride: configWithWorkerAgent,
|
||||
});
|
||||
|
|
@ -1770,7 +1772,11 @@ describe("check (single session)", () => {
|
|||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "reaction.triggered",
|
||||
data: expect.objectContaining({ reactionKey: "pr-closed" }),
|
||||
data: expect.objectContaining({
|
||||
schemaVersion: 3,
|
||||
semanticType: "pr.closed",
|
||||
reaction: expect.objectContaining({ key: "pr-closed" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1867,10 +1873,11 @@ describe("reactions", () => {
|
|||
|
||||
const reactionNotifications = vi.mocked(notifier.notify).mock.calls.filter((call) => {
|
||||
const event = call[0] as { type?: string; data?: Record<string, unknown> } | undefined;
|
||||
return (
|
||||
event?.type === "reaction.triggered" &&
|
||||
event.data?.["reactionKey"] === "report-no-acknowledge"
|
||||
);
|
||||
const reaction =
|
||||
event?.data?.reaction && typeof event.data.reaction === "object"
|
||||
? (event.data.reaction as Record<string, unknown>)
|
||||
: null;
|
||||
return event?.type === "reaction.triggered" && reaction?.key === "report-no-acknowledge";
|
||||
});
|
||||
|
||||
expect(reactionNotifications).toHaveLength(1);
|
||||
|
|
@ -2839,6 +2846,112 @@ describe("reactions", () => {
|
|||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "merge.completed" }),
|
||||
);
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.success).toBe(1);
|
||||
expect(
|
||||
summary.projects["my-app"]?.recentTraces.some(
|
||||
(trace) =>
|
||||
trace.operation === "notification.deliver" &&
|
||||
trace.outcome === "success" &&
|
||||
trace.data?.["targetReference"] === "desktop",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("records notifier delivery failures without interrupting lifecycle transitions", async () => {
|
||||
const notifier = createMockNotifier();
|
||||
vi.mocked(notifier.notify).mockRejectedValue(new Error("webhook failed"));
|
||||
const mockSCM = createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }),
|
||||
});
|
||||
|
||||
const registry: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return plugins.runtime;
|
||||
if (slot === "agent") return plugins.agent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
if (slot === "notifier" && name === "desktop") return notifier;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "approved", pr: makePR() }),
|
||||
registry,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("merged");
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "notifier",
|
||||
kind: "notification.delivery_failed",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({
|
||||
eventType: "merge.completed",
|
||||
targetReference: "desktop",
|
||||
targetPlugin: "desktop",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1);
|
||||
expect(summary.projects["my-app"]?.health["notification.delivery.desktop"]?.status).toBe(
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("records missing notifier targets as delivery failures", async () => {
|
||||
const mockSCM = createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }),
|
||||
});
|
||||
const configWithMissingNotifier: OrchestratorConfig = {
|
||||
...config,
|
||||
notificationRouting: {
|
||||
...config.notificationRouting,
|
||||
action: ["missing"],
|
||||
},
|
||||
};
|
||||
|
||||
const registry: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return plugins.runtime;
|
||||
if (slot === "agent") return plugins.agent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "approved", pr: makePR() }),
|
||||
registry,
|
||||
configOverride: configWithMissingNotifier,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "notifier",
|
||||
kind: "notification.target_missing",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({
|
||||
eventType: "merge.completed",
|
||||
targetReference: "missing",
|
||||
targetPlugin: "missing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = readObservabilitySummary(configWithMissingNotifier);
|
||||
expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves notifier aliases from notificationRouting before dispatch", async () => {
|
||||
|
|
@ -3344,22 +3457,34 @@ describe("reactions", () => {
|
|||
// Reach escalated state: attempt 1 → send, attempt 2 → escalate
|
||||
await lm.check("app-1"); // pr_open → ci_failed: attempt 1, send
|
||||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1"); // ci_failed → pr_open
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1"); // pr_open → ci_failed: attempt 2 → escalate
|
||||
expect(notifier.notify).toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" }));
|
||||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "reaction.escalated" }),
|
||||
);
|
||||
vi.mocked(notifier.notify).mockClear();
|
||||
|
||||
// ONE passing poll (stableCount = 1, not enough)
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
|
||||
// Next CI failure: tracker still escalated → short-circuit
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).not.toHaveBeenCalled();
|
||||
expect(notifier.notify).not.toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" }));
|
||||
expect(notifier.notify).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "reaction.escalated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("pending CI does not count toward ci-failed tracker resolution", async () => {
|
||||
|
|
@ -3401,12 +3526,16 @@ describe("reactions", () => {
|
|||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
|
||||
// CI goes pending (agent pushed a fix, new run started): ci_failed → pr_open
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "pending" }),
|
||||
);
|
||||
await lm.check("app-1"); // stableCount must NOT increment
|
||||
await lm.check("app-1"); // two pending polls — must NOT clear tracker
|
||||
|
||||
// CI fails again (run completed failing): pr_open → ci_failed, attempt 2 — send
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
// If pending had wrongly cleared the tracker, this would be attempt 1 (fresh), not attempt 2.
|
||||
// Attempt 2 ≤ retries:2 → sends to agent (not escalates)
|
||||
|
|
@ -3414,10 +3543,14 @@ describe("reactions", () => {
|
|||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
|
||||
// CI goes pending again, then failing — attempt 3 > retries:2 → escalate
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "pending" }),
|
||||
);
|
||||
await lm.check("app-1"); // pending: no clear
|
||||
await lm.check("app-1"); // pending: no clear
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).not.toHaveBeenCalled(); // escalated, not sent to agent
|
||||
});
|
||||
|
|
@ -3461,25 +3594,35 @@ describe("reactions", () => {
|
|||
// Reach escalated state: attempt 1 → send, attempt 2 → escalate
|
||||
await lm.check("app-1"); // attempt 1, send
|
||||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1"); // ci_failed → pr_open
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1"); // attempt 2 → escalate
|
||||
vi.mocked(notifier.notify).mockClear();
|
||||
|
||||
// CI goes pending (new run) — stableCount stays 0, does NOT progress toward resolution
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "pending" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
await lm.check("app-1");
|
||||
await lm.check("app-1"); // many pending polls — stableCount never reaches threshold
|
||||
|
||||
// CI finally passes (2 stable polls) → tracker cleared
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1"); // stableCount = 1
|
||||
await lm.check("app-1"); // stableCount = 2 → clearReactionTracker
|
||||
|
||||
// Next CI failure gets fresh budget: attempt 1, send
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).toHaveBeenCalledTimes(1);
|
||||
expect(notifier.notify).not.toHaveBeenCalledWith(
|
||||
|
|
@ -3632,7 +3775,11 @@ describe("pollAll terminal status accounting", () => {
|
|||
.mock.calls.filter((call: unknown[]) => {
|
||||
const event = call[0] as Record<string, unknown> | undefined;
|
||||
const data = event?.data as Record<string, unknown> | undefined;
|
||||
return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete";
|
||||
const reaction =
|
||||
data?.reaction && typeof data.reaction === "object"
|
||||
? (data.reaction as Record<string, unknown>)
|
||||
: null;
|
||||
return event?.type === "reaction.triggered" && reaction?.key === "all-complete";
|
||||
});
|
||||
expect(allCompleteNotifications).toHaveLength(0);
|
||||
|
||||
|
|
@ -4210,14 +4357,19 @@ describe("event enrichment", () => {
|
|||
expect.objectContaining({
|
||||
type: "pr.closed",
|
||||
data: expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
schemaVersion: 3,
|
||||
subject: expect.objectContaining({
|
||||
pr: expect.objectContaining({
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
number: 42,
|
||||
}),
|
||||
branch: "feat/test-123",
|
||||
}),
|
||||
schemaVersion: 2,
|
||||
transition: expect.objectContaining({
|
||||
kind: "pr_state",
|
||||
from: "none",
|
||||
to: "closed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -4257,11 +4409,13 @@ describe("event enrichment", () => {
|
|||
expect.objectContaining({
|
||||
type: "pr.closed",
|
||||
data: expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
issueId: "INT-123",
|
||||
issueTitle: "Fix login bug",
|
||||
schemaVersion: 3,
|
||||
subject: expect.objectContaining({
|
||||
issue: {
|
||||
id: "INT-123",
|
||||
title: "Fix login bug",
|
||||
},
|
||||
}),
|
||||
schemaVersion: 2,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -4305,11 +4459,15 @@ describe("event enrichment", () => {
|
|||
expect.objectContaining({
|
||||
type: "session.needs_input",
|
||||
data: expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
pr: null,
|
||||
issueId: "INT-456",
|
||||
schemaVersion: 3,
|
||||
subject: expect.objectContaining({
|
||||
issue: { id: "INT-456" },
|
||||
}),
|
||||
transition: expect.objectContaining({
|
||||
kind: "session_status",
|
||||
from: "working",
|
||||
to: "needs_input",
|
||||
}),
|
||||
schemaVersion: 2,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, renameSync } from "node:fs";
|
||||
import type * as NodeFs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
|
@ -13,12 +14,29 @@ import {
|
|||
deleteMetadata,
|
||||
listMetadata,
|
||||
} from "../metadata.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>();
|
||||
return {
|
||||
...actual,
|
||||
renameSync: vi.fn((...args: Parameters<typeof actual.renameSync>) =>
|
||||
actual.renameSync(...args),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-metadata-${randomUUID()}`);
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
vi.mocked(renameSync).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -390,6 +408,123 @@ describe("mutateMetadata corrupt-file handling", () => {
|
|||
const corruptCopies = readdirSync(dataDir).filter((f) => f.includes(".corrupt-"));
|
||||
expect(corruptCopies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits metadata.corrupt_detected when JSON parse fails and file is renamed", () => {
|
||||
const sessionPath = join(dataDir, "ao-3.json");
|
||||
writeFileSync(sessionPath, "{ broken json", "utf-8");
|
||||
|
||||
const result = mutateMetadata(
|
||||
dataDir,
|
||||
"ao-3",
|
||||
(existing) => ({ ...existing, branch: "feat/x" }),
|
||||
{ createIfMissing: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "ao-3",
|
||||
source: "session-manager",
|
||||
kind: "metadata.corrupt_detected",
|
||||
level: "error",
|
||||
summary: expect.stringContaining("renamed to"),
|
||||
data: expect.objectContaining({
|
||||
renamedTo: expect.stringContaining(`${sessionPath}.corrupt-`),
|
||||
renameSucceeded: true,
|
||||
contentSample: "{ broken json",
|
||||
path: sessionPath,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a rename-failed summary when corrupt metadata cannot be renamed", () => {
|
||||
const sessionPath = join(dataDir, "ao-rename-failed.json");
|
||||
writeFileSync(sessionPath, "{ broken json", "utf-8");
|
||||
vi.mocked(renameSync).mockImplementationOnce(() => {
|
||||
throw new Error("rename denied");
|
||||
});
|
||||
|
||||
const result = mutateMetadata(
|
||||
dataDir,
|
||||
"ao-rename-failed",
|
||||
(existing) => ({ ...existing, branch: "feat/x" }),
|
||||
{ createIfMissing: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const call = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.find((c) => c[0].kind === "metadata.corrupt_detected");
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toMatchObject({
|
||||
sessionId: "ao-rename-failed",
|
||||
summary: expect.stringContaining("failed to rename"),
|
||||
data: expect.objectContaining({
|
||||
renamedTo: null,
|
||||
renameSucceeded: false,
|
||||
path: sessionPath,
|
||||
}),
|
||||
});
|
||||
expect(call![0].summary).not.toContain("renamed to");
|
||||
});
|
||||
|
||||
it("uses the provided source for metadata.corrupt_detected", () => {
|
||||
const sessionPath = join(dataDir, "ao-api-source.json");
|
||||
writeFileSync(sessionPath, "{ broken json", "utf-8");
|
||||
|
||||
mutateMetadata(
|
||||
dataDir,
|
||||
"ao-api-source",
|
||||
(existing) => ({ ...existing, branch: "feat/api" }),
|
||||
{ createIfMissing: true, activityEventSource: "api" },
|
||||
);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "ao-api-source",
|
||||
source: "api",
|
||||
kind: "metadata.corrupt_detected",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates contentSample to 200 chars in metadata.corrupt_detected", () => {
|
||||
const sessionPath = join(dataDir, "ao-4.json");
|
||||
// 250 char garbage payload — sanitizer cap is 16KB but invariant B11 caps
|
||||
// forensic sample at 200 chars.
|
||||
const huge = "x".repeat(250);
|
||||
writeFileSync(sessionPath, huge, "utf-8");
|
||||
|
||||
mutateMetadata(
|
||||
dataDir,
|
||||
"ao-4",
|
||||
(existing) => ({ ...existing, branch: "feat/y" }),
|
||||
{ createIfMissing: true },
|
||||
);
|
||||
|
||||
const call = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.find((c) => c[0].kind === "metadata.corrupt_detected");
|
||||
expect(call).toBeDefined();
|
||||
const sample = (call![0].data as Record<string, unknown>)["contentSample"] as string;
|
||||
expect(sample.length).toBe(200);
|
||||
expect((call![0].data as Record<string, unknown>)["contentLength"]).toBe(250);
|
||||
});
|
||||
|
||||
it("does not emit metadata.corrupt_detected for healthy JSON", () => {
|
||||
writeMetadata(dataDir, "ao-5", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
});
|
||||
mutateMetadata(dataDir, "ao-5", (existing) => ({ ...existing, summary: "hi" }));
|
||||
|
||||
const corruptCalls = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.filter((c) => c[0].kind === "metadata.corrupt_detected");
|
||||
expect(corruptCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCanonicalLifecycle", () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from "../migration/storage-v2.js";
|
||||
import { readMetadata } from "../metadata.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 20_000 });
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCIFailureNotificationData,
|
||||
buildPRStateNotificationData,
|
||||
buildReactionEscalationNotificationData,
|
||||
buildReactionNotificationData,
|
||||
buildSessionTransitionNotificationData,
|
||||
getNotificationDataV3,
|
||||
semanticTypeForReactionKey,
|
||||
type NotificationEventContext,
|
||||
} from "../notification-data.js";
|
||||
|
||||
const context: NotificationEventContext = {
|
||||
pr: {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/app/pull/42",
|
||||
title: "Normalize notifier payloads",
|
||||
branch: "ao/notifier-payloads",
|
||||
baseBranch: "main",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
isDraft: false,
|
||||
},
|
||||
issueId: "AO-42",
|
||||
issueTitle: "Notifier payloads",
|
||||
summary: "Normalize notifier payloads",
|
||||
branch: "ao/notifier-payloads",
|
||||
};
|
||||
|
||||
describe("notification data v3", () => {
|
||||
it("builds semantic session transition data without legacy flat fields", () => {
|
||||
const data = buildSessionTransitionNotificationData({
|
||||
eventType: "session.needs_input",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
oldStatus: "working",
|
||||
newStatus: "needs_input",
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
semanticType: "session.needs_input",
|
||||
subject: {
|
||||
session: { id: "worker-1", projectId: "demo" },
|
||||
pr: { number: 42, url: "https://github.com/acme/app/pull/42" },
|
||||
issue: { id: "AO-42" },
|
||||
},
|
||||
transition: { kind: "session_status", from: "working", to: "needs_input" },
|
||||
});
|
||||
expect(data).not.toHaveProperty("context");
|
||||
expect(data).not.toHaveProperty("prUrl");
|
||||
expect(data).not.toHaveProperty("oldStatus");
|
||||
expect(data).not.toHaveProperty("newStatus");
|
||||
});
|
||||
|
||||
it("builds CI failure data with nested check details", () => {
|
||||
const data = buildCIFailureNotificationData({
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
failedChecks: [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/app/actions/runs/1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
semanticType: "ci.failing",
|
||||
ci: {
|
||||
status: "failing",
|
||||
failedChecks: [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/app/actions/runs/1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(data).not.toHaveProperty("failedChecks", ["typecheck"]);
|
||||
});
|
||||
|
||||
it("maps reaction keys to semantic domain blocks", () => {
|
||||
const data = buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
reactionKey: "approved-and-green",
|
||||
action: "notify",
|
||||
enrichment: {
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
hasConflicts: false,
|
||||
isBehind: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.semanticType).toBe("merge.ready");
|
||||
expect(data.reaction).toEqual({ key: "approved-and-green", action: "notify" });
|
||||
expect(data.ci).toEqual({ status: "passing" });
|
||||
expect(data.review).toEqual({ decision: "approved" });
|
||||
expect(data.merge).toMatchObject({ ready: true, conflicts: false, baseBranch: "main" });
|
||||
});
|
||||
|
||||
it("adds escalation details to reaction data", () => {
|
||||
const data = buildReactionEscalationNotificationData({
|
||||
eventType: "reaction.escalated",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
reactionKey: "ci-failed",
|
||||
action: "escalated",
|
||||
attempts: 4,
|
||||
cause: "max_retries",
|
||||
durationMs: 12_000,
|
||||
});
|
||||
|
||||
expect(data.semanticType).toBe("ci.failing");
|
||||
expect(data.escalation).toEqual({
|
||||
attempts: 4,
|
||||
cause: "max_retries",
|
||||
durationMs: 12_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds PR state transition data", () => {
|
||||
const data = buildPRStateNotificationData({
|
||||
eventType: "pr.closed",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
oldPRState: "open",
|
||||
newPRState: "closed",
|
||||
});
|
||||
|
||||
expect(data.transition).toEqual({ kind: "pr_state", from: "open", to: "closed" });
|
||||
expect(data.subject.pr?.url).toBe("https://github.com/acme/app/pull/42");
|
||||
expect(data).not.toHaveProperty("oldPRState");
|
||||
expect(data).not.toHaveProperty("newPRState");
|
||||
});
|
||||
|
||||
it("recognizes only v3 notification data", () => {
|
||||
const data = buildCIFailureNotificationData({
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
failedChecks: [],
|
||||
});
|
||||
|
||||
expect(getNotificationDataV3(data)).toBe(data);
|
||||
expect(getNotificationDataV3({ schemaVersion: 2, prUrl: context.pr?.url })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to event type for unknown reaction keys", () => {
|
||||
expect(semanticTypeForReactionKey("custom-reaction", "reaction.triggered")).toBe(
|
||||
"reaction.triggered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -159,6 +159,105 @@ describe("observability snapshot", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("uses YAML observability log level and stderr settings", () => {
|
||||
const originalLogLevel = process.env["AO_LOG_LEVEL"];
|
||||
const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"];
|
||||
delete process.env["AO_LOG_LEVEL"];
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
config.observability = { logLevel: "warn", stderr: true };
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
observer.recordOperation({
|
||||
metric: "spawn",
|
||||
operation: "notification.success-no-audit",
|
||||
outcome: "success",
|
||||
correlationId: "corr-info",
|
||||
projectId: "my-app",
|
||||
level: "info",
|
||||
});
|
||||
observer.recordOperation({
|
||||
metric: "notification_delivery",
|
||||
operation: "notification.deliver",
|
||||
outcome: "failure",
|
||||
correlationId: "corr-warn",
|
||||
projectId: "my-app",
|
||||
sessionId: "app-1",
|
||||
reason: "notifier target not found",
|
||||
level: "warn",
|
||||
});
|
||||
|
||||
const auditDir = join(getObservabilityBaseDir(config.configPath), "processes");
|
||||
const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson"));
|
||||
const auditLog = auditFiles
|
||||
.map((fileName) => readFileSync(join(auditDir, fileName), "utf-8"))
|
||||
.join("\n");
|
||||
|
||||
expect(auditLog).toContain('"operation":"notification.deliver"');
|
||||
expect(auditLog).not.toContain("notification.success-no-audit");
|
||||
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(stderrSpy.mock.calls[0]?.[0])).toContain('"operation":"notification.deliver"');
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env["AO_LOG_LEVEL"];
|
||||
} else {
|
||||
process.env["AO_LOG_LEVEL"] = originalLogLevel;
|
||||
}
|
||||
if (originalObservabilityStderr === undefined) {
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
} else {
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("lets observability env vars override YAML settings", () => {
|
||||
const originalLogLevel = process.env["AO_LOG_LEVEL"];
|
||||
const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"];
|
||||
process.env["AO_LOG_LEVEL"] = "info";
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = "0";
|
||||
config.observability = { logLevel: "error", stderr: true };
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
observer.recordOperation({
|
||||
metric: "notification_delivery",
|
||||
operation: "notification.deliver",
|
||||
outcome: "success",
|
||||
correlationId: "corr-env",
|
||||
projectId: "my-app",
|
||||
sessionId: "app-1",
|
||||
level: "info",
|
||||
});
|
||||
|
||||
const auditDir = join(getObservabilityBaseDir(config.configPath), "processes");
|
||||
const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson"));
|
||||
const auditLog = auditFiles
|
||||
.map((fileName) => readFileSync(join(auditDir, fileName), "utf-8"))
|
||||
.join("\n");
|
||||
|
||||
expect(auditLog).toContain('"operation":"notification.deliver"');
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env["AO_LOG_LEVEL"];
|
||||
} else {
|
||||
process.env["AO_LOG_LEVEL"] = originalLogLevel;
|
||||
}
|
||||
if (originalObservabilityStderr === undefined) {
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
} else {
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts sensitive observability payload fields before persisting them", () => {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ describe("platform adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getNodePtyPrebuildsSubdir", () => {
|
||||
it("centralizes node-pty prebuild platform/arch naming", async () => {
|
||||
setPlatform("darwin");
|
||||
const mod = await import("../platform.js");
|
||||
expect(mod.getNodePtyPrebuildsSubdir()).toBe(`darwin-${process.arch}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShell", () => {
|
||||
it("always returns /bin/sh on unix (ignores $SHELL)", async () => {
|
||||
setPlatform("linux");
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ vi.mock("node:child_process", () => {
|
|||
const execFile = Object.assign(vi.fn(), {
|
||||
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
|
||||
});
|
||||
return { execFile };
|
||||
return { execFile, exec: vi.fn() };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -151,11 +151,13 @@ describe("loadBuiltins", () => {
|
|||
|
||||
const fakeClaudeCode = makePlugin("agent", "claude-code");
|
||||
const fakeCodex = makePlugin("agent", "codex");
|
||||
const fakeGrok = makePlugin("agent", "grok");
|
||||
const fakeOpenCode = makePlugin("agent", "opencode");
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-agent-claude-code") return fakeClaudeCode;
|
||||
if (pkg === "@aoagents/ao-plugin-agent-codex") return fakeCodex;
|
||||
if (pkg === "@aoagents/ao-plugin-agent-grok") return fakeGrok;
|
||||
if (pkg === "@aoagents/ao-plugin-agent-opencode") return fakeOpenCode;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
|
@ -163,10 +165,12 @@ describe("loadBuiltins", () => {
|
|||
const agents = registry.list("agent");
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" }));
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" }));
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "grok", slot: "agent" }));
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "opencode", slot: "agent" }));
|
||||
|
||||
expect(registry.get("agent", "codex")).not.toBeNull();
|
||||
expect(registry.get("agent", "claude-code")).not.toBeNull();
|
||||
expect(registry.get("agent", "grok")).not.toBeNull();
|
||||
expect(registry.get("agent", "opencode")).not.toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -348,6 +352,68 @@ describe("loadBuiltins", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("passes configPath for named notifier references without explicit config", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeDashboard = makePlugin("notifier", "dashboard");
|
||||
const cfg = makeOrchestratorConfig({
|
||||
configPath: "/test/config.yaml",
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "codex",
|
||||
workspace: "worktree",
|
||||
notifiers: ["dashboard"],
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["dashboard"],
|
||||
action: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
notifiers: {},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(cfg, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeDashboard.create).toHaveBeenCalledWith({
|
||||
configPath: "/test/config.yaml",
|
||||
});
|
||||
expect(
|
||||
registry.get<{ _config: Record<string, unknown> }>("notifier", "dashboard")?._config,
|
||||
).toEqual({
|
||||
configPath: "/test/config.yaml",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create an implicit notifier registration over a conflicting explicit entry", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeDashboard = makePlugin("notifier", "dashboard");
|
||||
const cfg = makeOrchestratorConfig({
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "codex",
|
||||
workspace: "worktree",
|
||||
notifiers: ["dashboard"],
|
||||
},
|
||||
notifiers: {
|
||||
dashboard: {
|
||||
plugin: "webhook",
|
||||
url: "https://hooks.example.com/dashboard",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(cfg, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeDashboard.create).not.toHaveBeenCalled();
|
||||
expect(registry.get("notifier", "dashboard")).toBeNull();
|
||||
});
|
||||
|
||||
it("strips package loading metadata from notifier config", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeWebhook = makePlugin("notifier", "webhook");
|
||||
|
|
@ -427,7 +493,7 @@ describe("loadBuiltins", () => {
|
|||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined);
|
||||
expect(fakeOpenClaw.create).not.toHaveBeenCalled();
|
||||
expect(fakeWebhook.create).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 3,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ function makeAssessment(overrides: Partial<RecoveryAssessment> = {}): RecoveryAs
|
|||
metadataStatus: "working",
|
||||
rawMetadata: {
|
||||
project: "app",
|
||||
agent: "claude-code",
|
||||
branch: "feat/test",
|
||||
issue: "123",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
|
|
@ -169,6 +170,7 @@ describe("recoverSession", () => {
|
|||
expect(result.session?.restoredAt).toBeInstanceOf(Date);
|
||||
expect(metadata?.["restoredAt"]).toBeDefined();
|
||||
expect(metadata?.["recoveredAt"]).toBeUndefined();
|
||||
expect(metadata?.["agent"]).toBe("claude-code");
|
||||
});
|
||||
|
||||
it("preserves project ownership when legacy metadata omits the project field", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { recoverSessionById, runRecovery } from "../recovery/manager.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import { getProjectDir, getProjectSessionsDir } from "../paths.js";
|
||||
import {
|
||||
DEFAULT_RECOVERY_CONFIG,
|
||||
type RecoveryAssessment,
|
||||
type RecoveryResult,
|
||||
} from "../recovery/types.js";
|
||||
import * as actionsModule from "../recovery/actions.js";
|
||||
import * as validatorModule from "../recovery/validator.js";
|
||||
import type { OrchestratorConfig, PluginRegistry } from "../types.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const PROJECT_ID = "app";
|
||||
|
||||
function makeConfig(rootDir: string): OrchestratorConfig {
|
||||
return {
|
||||
configPath: join(rootDir, "agent-orchestrator.yaml"),
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
power: { preventIdleSleep: false },
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
app: {
|
||||
name: "app",
|
||||
repo: "org/repo",
|
||||
path: join(rootDir, "project"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: ["desktop"],
|
||||
info: ["desktop"],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(): PluginRegistry {
|
||||
return {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssessment(sessionId: string): RecoveryAssessment {
|
||||
return {
|
||||
sessionId,
|
||||
projectId: PROJECT_ID,
|
||||
classification: "live",
|
||||
action: "recover",
|
||||
reason: "needs recovery",
|
||||
runtimeProbeSucceeded: true,
|
||||
processProbeSucceeded: true,
|
||||
signalDisagreement: false,
|
||||
recoveryRule: "auto",
|
||||
runtimeAlive: true,
|
||||
runtimeHandle: null,
|
||||
workspaceExists: true,
|
||||
workspacePath: "/tmp/worktree",
|
||||
agentProcessRunning: true,
|
||||
agentActivity: "active",
|
||||
metadataValid: true,
|
||||
metadataStatus: "working",
|
||||
rawMetadata: { project: PROJECT_ID, status: "working" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("runRecovery activity events", () => {
|
||||
let rootDir: string;
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-events-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
mkdirSync(join(rootDir, "project"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
previousHome = process.env["HOME"];
|
||||
process.env["HOME"] = rootDir;
|
||||
|
||||
const sessionsDir = getProjectSessionsDir(PROJECT_ID);
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionsDir, "app-1.json"),
|
||||
JSON.stringify({ project: PROJECT_ID, status: "working" }) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(sessionsDir, "app-2.json"),
|
||||
JSON.stringify({ project: PROJECT_ID, status: "working" }) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = previousHome;
|
||||
}
|
||||
if (rootDir) {
|
||||
const projectBaseDir = getProjectDir(PROJECT_ID);
|
||||
if (existsSync(projectBaseDir)) {
|
||||
rmSync(projectBaseDir, { recursive: true, force: true });
|
||||
}
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits recovery.session_failed for each session that recovery couldn't fix", async () => {
|
||||
vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) =>
|
||||
makeAssessment(scanned.sessionId),
|
||||
);
|
||||
|
||||
const successResult: RecoveryResult = {
|
||||
success: true,
|
||||
sessionId: "app-1",
|
||||
action: "recover",
|
||||
};
|
||||
const failedResult: RecoveryResult = {
|
||||
success: false,
|
||||
sessionId: "app-2",
|
||||
action: "recover",
|
||||
error: "worktree missing",
|
||||
};
|
||||
|
||||
vi.spyOn(actionsModule, "executeAction")
|
||||
.mockResolvedValueOnce(successResult)
|
||||
.mockResolvedValueOnce(failedResult);
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const registry = makeRegistry();
|
||||
|
||||
const { report } = await runRecovery({
|
||||
config,
|
||||
registry,
|
||||
recoveryConfig: {
|
||||
...DEFAULT_RECOVERY_CONFIG,
|
||||
logPath: join(rootDir, "recovery.log"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(report.errors).toHaveLength(1);
|
||||
expect(report.errors[0]?.sessionId).toBe("app-2");
|
||||
|
||||
const emitCalls = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((e) => e.kind === "recovery.session_failed");
|
||||
|
||||
expect(emitCalls).toHaveLength(1);
|
||||
expect(emitCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionId: "app-2",
|
||||
projectId: PROJECT_ID,
|
||||
source: "recovery",
|
||||
kind: "recovery.session_failed",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
action: "recover",
|
||||
errorMessage: "worktree missing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits recovery.session_failed when single-session recovery fails", async () => {
|
||||
vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) =>
|
||||
makeAssessment(scanned.sessionId),
|
||||
);
|
||||
|
||||
const failedResult: RecoveryResult = {
|
||||
success: false,
|
||||
sessionId: "app-2",
|
||||
action: "recover",
|
||||
error: "agent process missing",
|
||||
};
|
||||
|
||||
vi.spyOn(actionsModule, "executeAction").mockResolvedValueOnce(failedResult);
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const registry = makeRegistry();
|
||||
|
||||
const result = await recoverSessionById("app-2", {
|
||||
config,
|
||||
registry,
|
||||
recoveryConfig: {
|
||||
...DEFAULT_RECOVERY_CONFIG,
|
||||
logPath: join(rootDir, "recovery.log"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(failedResult);
|
||||
|
||||
const emitCalls = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((e) => e.kind === "recovery.session_failed");
|
||||
|
||||
expect(emitCalls).toHaveLength(1);
|
||||
expect(emitCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionId: "app-2",
|
||||
projectId: PROJECT_ID,
|
||||
source: "recovery",
|
||||
kind: "recovery.session_failed",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
action: "recover",
|
||||
errorMessage: "agent process missing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit recovery.session_failed when every session recovers cleanly", async () => {
|
||||
vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) =>
|
||||
makeAssessment(scanned.sessionId),
|
||||
);
|
||||
|
||||
vi.spyOn(actionsModule, "executeAction").mockImplementation(async (assessment) => ({
|
||||
success: true,
|
||||
sessionId: assessment.sessionId,
|
||||
action: "recover",
|
||||
}));
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const registry = makeRegistry();
|
||||
|
||||
await runRecovery({
|
||||
config,
|
||||
registry,
|
||||
recoveryConfig: {
|
||||
...DEFAULT_RECOVERY_CONFIG,
|
||||
logPath: join(rootDir, "recovery.log"),
|
||||
},
|
||||
});
|
||||
|
||||
const failedEmits = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((e) => e.kind === "recovery.session_failed");
|
||||
expect(failedEmits).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -119,6 +119,7 @@ describe("recovery validator", () => {
|
|||
const assessment = await validateSession(scanned, config, registry);
|
||||
|
||||
expect(assessment.agentProcessRunning).toBe(true);
|
||||
expect(assessment.rawMetadata["agent"]).toBe("codex");
|
||||
expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled();
|
||||
expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,635 @@
|
|||
/**
|
||||
* Regression tests for session-manager activity event instrumentation
|
||||
* (issue #1657 — extends PR #1620 to cover the rest of the failure paths
|
||||
* inside spawn / kill / restore / send / list).
|
||||
*
|
||||
* One test per MUST-class emit. Pattern follows the lifecycle-manager-
|
||||
* instrumentation tests: mock `recordActivityEvent`, drive the manager into
|
||||
* the failure path, then assert the right kind/level/data was logged.
|
||||
*
|
||||
* Invariants asserted by these tests (PR #1620 B1/B2 plus #1657 B25):
|
||||
* - state mutation happens BEFORE event emission
|
||||
* - failure-only emits — no event on a successful send/spawn
|
||||
* - cleanup-stack rollbacks emit per failed step (not in aggregate)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { writeMetadata, readMetadataRaw } from "../metadata.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import { getProjectWorktreesDir } from "../paths.js";
|
||||
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
|
||||
import {
|
||||
setupTestContext,
|
||||
teardownTestContext,
|
||||
makeHandle,
|
||||
type TestContext,
|
||||
} from "./test-utils.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let ctx: TestContext;
|
||||
let sessionsDir: string;
|
||||
let mockRegistry: PluginRegistry;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = setupTestContext();
|
||||
({ sessionsDir, mockRegistry, config } = ctx);
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownTestContext(ctx);
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function findEvent(kind: string) {
|
||||
return vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((e) => e.kind === kind);
|
||||
}
|
||||
|
||||
function findAllEvents(kind: string) {
|
||||
return vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((e) => e.kind === kind);
|
||||
}
|
||||
|
||||
function writeTerminatedSession(
|
||||
sessionId: string,
|
||||
options: { worktree: string; branch?: string },
|
||||
): void {
|
||||
const metadata: Record<string, unknown> = {
|
||||
worktree: options.worktree,
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (options.branch !== undefined) {
|
||||
metadata["branch"] = options.branch;
|
||||
}
|
||||
writeMetadata(sessionsDir, sessionId, metadata as unknown as Parameters<typeof writeMetadata>[2]);
|
||||
}
|
||||
|
||||
describe("session.kill_started (MUST)", () => {
|
||||
it("emits before runtime.destroy is attempted", async () => {
|
||||
let destroyCalled = false;
|
||||
let killStartedEmittedBeforeDestroy = false;
|
||||
|
||||
vi.mocked(ctx.mockRuntime.destroy).mockImplementation(async () => {
|
||||
destroyCalled = true;
|
||||
killStartedEmittedBeforeDestroy = !!findEvent("session.kill_started");
|
||||
});
|
||||
|
||||
writeMetadata(sessionsDir, "app-killed", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-killed");
|
||||
|
||||
expect(destroyCalled).toBe(true);
|
||||
expect(killStartedEmittedBeforeDestroy).toBe(true);
|
||||
|
||||
const start = findEvent("session.kill_started");
|
||||
expect(start).toMatchObject({
|
||||
projectId: "my-app",
|
||||
sessionId: "app-killed",
|
||||
source: "session-manager",
|
||||
kind: "session.kill_started",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit kill_started when session is already terminated (idempotent)", async () => {
|
||||
writeMetadata(sessionsDir, "app-already-killed", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "manual_kill_requested",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-already-killed");
|
||||
|
||||
expect(findEvent("session.kill_started")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.spawn_failed — orchestrator path (MUST)", () => {
|
||||
it("emits session.spawned after a successful orchestrator spawn", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const session = await sm.spawnOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "be helpful",
|
||||
});
|
||||
|
||||
const events = findAllEvents("session.spawned");
|
||||
const orchestratorSpawned = events.find(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
);
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(orchestratorSpawned).toMatchObject({
|
||||
projectId: "my-app",
|
||||
sessionId: "app-orchestrator",
|
||||
source: "session-manager",
|
||||
kind: "session.spawned",
|
||||
summary: "spawned: app-orchestrator",
|
||||
data: {
|
||||
agent: "mock-agent",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
role: "orchestrator",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit terminal spawn_failed when ensure recovers a fixed reservation conflict", async () => {
|
||||
let releaseWorkspace: () => void = () => {};
|
||||
const blockingWorkspace = new Promise<void>((resolve) => {
|
||||
releaseWorkspace = resolve;
|
||||
});
|
||||
vi.mocked(ctx.mockWorkspace.create).mockImplementationOnce(async (cfg) => {
|
||||
await blockingWorkspace;
|
||||
return {
|
||||
path: join(ctx.tmpDir, "ws-orchestrator"),
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
});
|
||||
|
||||
const firstManager = createSessionManager({ config, registry: mockRegistry });
|
||||
const secondManager = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const firstEnsure = firstManager.ensureOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "be helpful",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.mockWorkspace.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const secondEnsure = secondManager.ensureOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "be helpful",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(findEvent("session.orchestrator_conflict")).toBeDefined();
|
||||
});
|
||||
|
||||
releaseWorkspace();
|
||||
const [created, recovered] = await Promise.all([firstEnsure, secondEnsure]);
|
||||
|
||||
expect(created.id).toBe("app-orchestrator");
|
||||
expect(recovered.id).toBe("app-orchestrator");
|
||||
expect(findAllEvents("session.orchestrator_conflict")).toHaveLength(1);
|
||||
expect(
|
||||
findAllEvents("session.spawn_failed").filter(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits one terminal failure plus one stage failure when workspace.create throws", async () => {
|
||||
vi.mocked(ctx.mockWorkspace.create).mockRejectedValue(new Error("disk full"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(
|
||||
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
|
||||
).rejects.toThrow("disk full");
|
||||
|
||||
const events = findAllEvents("session.spawn_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
const orchestratorFailure = events.find(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
);
|
||||
expect(orchestratorFailure).toBeDefined();
|
||||
expect(orchestratorFailure!.level).toBe("error");
|
||||
expect(orchestratorFailure!.projectId).toBe("my-app");
|
||||
|
||||
const stepEvents = findAllEvents("session.spawn_step_failed");
|
||||
expect(stepEvents).toHaveLength(1);
|
||||
expect(stepEvents[0]!.sessionId).toBe("app-orchestrator");
|
||||
expect(stepEvents[0]!.data).toMatchObject({
|
||||
role: "orchestrator",
|
||||
stage: "workspace_create",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits one terminal failure plus one stage failure when runtime.create throws", async () => {
|
||||
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("tmux not found"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(
|
||||
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
|
||||
).rejects.toThrow("tmux not found");
|
||||
|
||||
const events = findAllEvents("session.spawn_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
const orchestratorFailure = events.find(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
);
|
||||
expect(orchestratorFailure).toBeDefined();
|
||||
expect(orchestratorFailure!.level).toBe("error");
|
||||
|
||||
const stepEvents = findAllEvents("session.spawn_step_failed");
|
||||
expect(stepEvents).toHaveLength(1);
|
||||
expect(stepEvents[0]!.sessionId).toBe("app-orchestrator");
|
||||
expect(stepEvents[0]!.data).toMatchObject({
|
||||
role: "orchestrator",
|
||||
stage: "runtime_create",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.rollback_started/session.rollback_step_failed (MUST)", () => {
|
||||
it("includes reserved sessionId when worker spawn rolls back after reservation", async () => {
|
||||
const workspacePath = join(getProjectWorktreesDir("my-app"), "app-1");
|
||||
vi.mocked(ctx.mockWorkspace.create).mockResolvedValue({
|
||||
path: workspacePath,
|
||||
branch: "feat/test",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
});
|
||||
vi.mocked(ctx.mockWorkspace.destroy).mockRejectedValue(new Error("destroy failed"));
|
||||
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("runtime failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("runtime failed");
|
||||
|
||||
const rollbackStarted = findEvent("session.rollback_started");
|
||||
expect(rollbackStarted).toBeDefined();
|
||||
expect(rollbackStarted!.sessionId).toBe("app-1");
|
||||
|
||||
const rollbackStepFailed = findEvent("session.rollback_step_failed");
|
||||
expect(rollbackStepFailed).toBeDefined();
|
||||
expect(rollbackStepFailed!.sessionId).toBe("app-1");
|
||||
expect(rollbackStepFailed!.data).toMatchObject({ reason: "destroy failed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.workspace_hooks_failed (MUST)", () => {
|
||||
it("emits when setupWorkspaceHooks throws during orchestrator spawn", async () => {
|
||||
const hookFailingAgent: Agent = {
|
||||
...ctx.mockAgent,
|
||||
name: "hook-failing-agent",
|
||||
setupWorkspaceHooks: vi.fn().mockRejectedValue(new Error("settings.json EACCES")),
|
||||
};
|
||||
const originalGet = mockRegistry.get;
|
||||
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
|
||||
if (slot === "agent") return hookFailingAgent;
|
||||
return (originalGet as any)(slot, name);
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(
|
||||
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
|
||||
).rejects.toThrow("settings.json EACCES");
|
||||
|
||||
const event = findEvent("session.workspace_hooks_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.projectId).toBe("my-app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime.lost_detected (MUST)", () => {
|
||||
it("emits when sm.list() persists runtime_lost for a dead runtime", async () => {
|
||||
writeMetadata(sessionsDir, "app-dead", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-dead"),
|
||||
});
|
||||
|
||||
// Runtime claims dead, agent process gone
|
||||
vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.list();
|
||||
|
||||
const event = findEvent("runtime.lost_detected");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.projectId).toBe("my-app");
|
||||
expect(event!.sessionId).toBe("app-dead");
|
||||
expect(event!.level).toBe("warn");
|
||||
|
||||
// B1: state mutation BEFORE event emission — verify metadata was persisted
|
||||
const persisted = readMetadataRaw(sessionsDir, "app-dead");
|
||||
expect(persisted).not.toBeNull();
|
||||
const lifecycleStr = persisted!["lifecycle"];
|
||||
expect(lifecycleStr).toBeDefined();
|
||||
const lc = JSON.parse(lifecycleStr!) as { session: { state: string; reason: string } };
|
||||
expect(lc.session.state).toBe("detecting");
|
||||
expect(lc.session.reason).toBe("runtime_lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.send_failed (MUST)", () => {
|
||||
it("emits after send retry-with-restore exhausts", async () => {
|
||||
writeMetadata(sessionsDir, "app-send", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(ctx.mockRuntime.sendMessage).mockRejectedValue(new Error("send broke"));
|
||||
// restore() throws so retry-with-restore exhausts
|
||||
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("restore failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.send("app-send", "hi")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.send_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.sessionId).toBe("app-send");
|
||||
// B11: data must not contain message content
|
||||
expect(JSON.stringify(event!.data ?? {})).not.toContain("hi");
|
||||
});
|
||||
|
||||
it("does not double-emit session.restore_failed when restore fails during send", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-send-ws");
|
||||
writeTerminatedSession("app-send-restore", { worktree: wsPath, branch: "feat/send" });
|
||||
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("restore failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.send("app-send-restore", "hi")).rejects.toThrow();
|
||||
|
||||
const restoreFailed = findAllEvents("session.restore_failed");
|
||||
expect(restoreFailed).toHaveLength(1);
|
||||
expect(restoreFailed[0]!.data).toMatchObject({ stage: "workspace_restore" });
|
||||
expect(findEvent("session.send_failed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("tags restore-for-delivery timeout restore_failed with ready_timeout stage", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const wsPath = join(ctx.tmpDir, "send-restore-ready-timeout");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
writeTerminatedSession("app-send-timeout", { worktree: wsPath, branch: "feat/send" });
|
||||
|
||||
vi.mocked(ctx.mockRuntime.isAlive).mockImplementation(async (handle) => {
|
||||
return handle.id !== "rt-restored";
|
||||
});
|
||||
vi.mocked(ctx.mockAgent.isProcessRunning).mockImplementation(async (handle) => {
|
||||
return handle.id !== "rt-restored";
|
||||
});
|
||||
vi.mocked(ctx.mockRuntime.create).mockResolvedValue(makeHandle("rt-restored"));
|
||||
vi.mocked(ctx.mockRuntime.getOutput).mockResolvedValue("");
|
||||
vi.mocked(ctx.mockAgent.detectActivity).mockReturnValue("idle");
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const sendPromise = sm.send("app-send-timeout", "hi");
|
||||
const rejection = expect(sendPromise).rejects.toThrow(
|
||||
"restored session did not become ready for delivery",
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await rejection;
|
||||
|
||||
const restoreFailed = findAllEvents("session.restore_failed");
|
||||
expect(restoreFailed).toHaveLength(1);
|
||||
expect(restoreFailed[0]!.data).toMatchObject({
|
||||
stage: "ready_timeout",
|
||||
reason: "restored session did not become ready for delivery",
|
||||
trigger: "send",
|
||||
});
|
||||
expect(findEvent("session.send_failed")).toBeDefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.restore_failed (MUST)", () => {
|
||||
it("emits when restore's workspace restore throws", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-ws");
|
||||
writeMetadata(sessionsDir, "app-rest", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/x",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// workspace doesn't exist + restore throws
|
||||
vi.mocked(ctx.mockWorkspace.exists ?? (() => false)).mockResolvedValue?.(false);
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("clone failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-rest")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.sessionId).toBe("app-rest");
|
||||
});
|
||||
|
||||
it("emits SessionNotRestorableError when session is not restorable", async () => {
|
||||
writeMetadata(sessionsDir, "app-not-rest", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "feat/x",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "working",
|
||||
reason: "task_in_progress",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: null,
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "alive",
|
||||
reason: "process_running",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: makeHandle("rt-1"),
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// active session — not restorable
|
||||
vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(true);
|
||||
vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(true);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-not-rest")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits when workspace is missing and the workspace plugin cannot restore", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-no-restore");
|
||||
writeTerminatedSession("app-no-restore", { worktree: wsPath, branch: "feat/no-restore" });
|
||||
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = undefined;
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-no-restore")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.sessionId).toBe("app-no-restore");
|
||||
expect(event!.data).toMatchObject({
|
||||
stage: "workspace_restore",
|
||||
workspacePath: wsPath,
|
||||
reason: "workspace plugin does not support restore",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits when workspace is missing and branch metadata is absent", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-no-branch");
|
||||
writeTerminatedSession("app-no-branch", { worktree: wsPath });
|
||||
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = vi.fn().mockResolvedValue({
|
||||
path: wsPath,
|
||||
branch: "unused",
|
||||
sessionId: "app-no-branch",
|
||||
projectId: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-no-branch")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.sessionId).toBe("app-no-branch");
|
||||
expect(event!.data).toMatchObject({
|
||||
stage: "workspace_restore",
|
||||
workspacePath: wsPath,
|
||||
reason: "branch metadata is missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata.corrupt_detected (MUST)", () => {
|
||||
it("emits when mutateMetadata side-renames a corrupt file", async () => {
|
||||
// Simulate a corrupt metadata file in the sessions dir
|
||||
const sessionPath = join(sessionsDir, "app-corrupt.json");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeFileSync(sessionPath, "{ this is not json", "utf-8");
|
||||
|
||||
const { mutateMetadata } = await import("../metadata.js");
|
||||
mutateMetadata(sessionsDir, "app-corrupt", () => ({ branch: "feat/x", project: "my-app" }), {
|
||||
createIfMissing: true,
|
||||
activityEventSource: "api",
|
||||
});
|
||||
|
||||
const event = findEvent("metadata.corrupt_detected");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.source).toBe("api");
|
||||
expect(event!.sessionId).toBe("app-corrupt");
|
||||
const data = event!.data as Record<string, unknown>;
|
||||
expect(data["renameSucceeded"]).toBe(true);
|
||||
expect(data["renamedTo"]).toMatch(/\.corrupt-\d+$/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue