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:
Ashish Huddar 2026-05-22 10:54:40 +05:30
commit e4e073fb53
329 changed files with 47256 additions and 4364 deletions

View File

@ -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",

34
.changeset/hotfix-091.md Normal file
View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

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

View File

@ -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
```

View File

@ -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.

View File

@ -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"],

View File

@ -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

93
packages/ao/README.md Normal file
View File

@ -0,0 +1,93 @@
<div align="center">
# Agent Orchestrator (`ao`)
**The orchestration layer for parallel AI coding agents.**
[![npm version](https://img.shields.io/npm/v/%40aoagents%2Fao?style=flat-square)](https://www.npmjs.com/package/@aoagents/ao)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/blob/main/LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/ComposioHQ/agent-orchestrator?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](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)

View File

@ -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",

View File

@ -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

View File

@ -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);

View File

@ -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",
}),
);
});
});

View File

@ -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",
}),
}),
);
});
});

View File

@ -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();
});
});

View File

@ -23,6 +23,7 @@ vi.mock("@aoagents/ao-core", () => ({
isPortfolioEnabled: () => true,
getPortfolio: mockGetPortfolio,
getPortfolioSessionCounts: mockGetPortfolioSessionCounts,
recordActivityEvent: vi.fn(),
registerProject: mockRegisterProject,
unregisterProject: mockUnregisterProject,
loadPreferences: mockLoadPreferences,

View File

@ -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

View File

@ -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,

View File

@ -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",
}),
}),
);
});
});

View File

@ -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();

View File

@ -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(

View File

@ -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",
}),
}),
);
});
});

View File

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

View File

@ -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");
});
});

View File

@ -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();
}
});
});

View File

@ -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;

View File

@ -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");
});
});

View File

@ -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();
}
});
});

View File

@ -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", () => {

View File

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

View File

@ -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);

View File

@ -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"

View File

@ -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"

View File

@ -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) => {

View File

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

View File

@ -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,

View File

@ -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)),
);

View File

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

View File

@ -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")

View File

@ -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)}`,

View File

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

View File

@ -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 {

View File

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

View File

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

View File

@ -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 {

View File

@ -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" : ""}`
: "")
: ""),
),
);

View File

@ -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

View File

@ -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();
}

View File

@ -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;

View File

@ -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"));
}
}

View File

@ -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}`;
}

View File

@ -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"));
}
}

View File

@ -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" },
];

View File

@ -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"));
}
}

View File

@ -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";
}

View File

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

View File

@ -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"));
}
}

View File

@ -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,
};

View File

@ -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);

View File

@ -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 },
});
}
}

View File

@ -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)}`;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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"));
}
}

View File

@ -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)" : "";

View File

@ -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"));
}
}

View File

@ -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);

View File

@ -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

View File

@ -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({

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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([]);
});
});

View File

@ -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 = {

View File

@ -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

View File

@ -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,
},
]);
});
});

View File

@ -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/);
});
});

View File

@ -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();
});
});

View File

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

View File

@ -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");

View File

@ -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,
};

View File

@ -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,
}),
}),
);

View File

@ -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", () => {

View File

@ -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(),

View File

@ -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",
);
});
});

View File

@ -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");

View File

@ -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");

View File

@ -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() };
});
// ---------------------------------------------------------------------------

View File

@ -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,

View File

@ -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 () => {

View File

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

View File

@ -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();
});

View File

@ -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