Merge remote-tracking branch 'origin/main' into codex/implement-remote-control

# Conflicts:
#	packages/core/src/global-config.ts
#	packages/web/src/components/Dashboard.tsx
#	packages/web/src/components/SessionDetailHeader.tsx
This commit is contained in:
Ashish Huddar 2026-05-20 11:16:12 +05:30
commit 0f1a549cb5
270 changed files with 45126 additions and 5133 deletions

View File

@ -0,0 +1,18 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-web": minor
---
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.

View File

@ -0,0 +1,8 @@
---
"@aoagents/ao-core": patch
"@aoagents/ao-plugin-agent-claude-code": patch
---
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).

View File

@ -0,0 +1,17 @@
---
"@aoagents/ao-plugin-agent-claude-code": patch
---
Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.

View File

@ -0,0 +1,6 @@
---
"@aoagents/ao-core": minor
"@aoagents/ao-cli": minor
---
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.

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

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-web": patch
---
Fix Done/Terminated section on the dashboard not scrolling. The dashboard body now scrolls as a single page — kanban above, Done/Terminated below — so older terminated sessions are reachable when many accumulate. Restores the pre-Warm-Terminal-refresh behavior by dropping the `flex: 1` that was making `.kanban-board-wrap` greedily consume all remaining body height and defeat the body's `overflow-y: auto`.

View File

@ -0,0 +1,6 @@
---
"@aoagents/ao-cli": patch
"@aoagents/ao-core": minor
---
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.

View File

@ -0,0 +1,8 @@
---
"@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

@ -0,0 +1,5 @@
---
"@aoagents/ao-plugin-tracker-linear": patch
---
Retry transient Linear API HTTP failures in the direct transport to reduce flakes from brief 5xx/429 responses.

View File

@ -0,0 +1,14 @@
---
"@aoagents/ao-cli": patch
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
"@aoagents/ao-notifier-macos": 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
---
Add the notifier test harness, dashboard notifications, and desktop notifier setup.

View File

@ -0,0 +1,7 @@
---
"@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

@ -0,0 +1,19 @@
---
"@aoagents/ao-web": patch
---
fix(web): show Restore button for every exited session, including pr_merged
The Restore button was hidden for sessions exited with `pr_merged` reason (legacy
status `cleanup`) on the dashboard kanban and absent altogether from the
session-detail "Terminal ended" panel. The core `isRestorable()` helper already
allowed restoring these sessions; the dashboard helpers were out of sync. Fixes
#1907.
- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES` only,
matching core's `isRestorable`. Merged-but-running sessions remain
non-restorable (lifecycle isn't terminal).
- `DoneCard` no longer hides Restore for merged sessions.
- `SessionEndedSummary` exposes a prominent `Restore session` button next to
`Open PR` / `Back to dashboard`, so users don't have to find the small
header icon.

View File

@ -111,7 +111,14 @@ 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 cfg = require('./.changeset/config.json');
const pkgs = cfg.linked.flat();
let body = '---\n';
pkgs.forEach(p => body += '"' + p + '": patch\n');
body += '---\n\nchore: canary build\n';
require('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

@ -111,7 +111,7 @@ spawning -> working -> pr_open -> ci_failed / review_pending
+-> mergeable -> merged -> cleanup -> done
```
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `runtime_lost` reason to disk. This maps to legacy status `killed`. Without this, sessions with dead runtimes would show stale "active" status indefinitely.
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735).
### Data Flow
@ -224,7 +224,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- Kanban board filters client-side via `projectSessions` memo
### Key invariants
- `sm.list()` persists `runtime_lost` lifecycle to disk when enrichment detects dead runtimes — this is the only place stale runtime state gets reconciled
- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735)
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
- Tab completions merge local config + global config to show all projects
@ -450,8 +450,8 @@ import {
validateUrl, // Webhook URL validation
readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL)
readLastActivityEntry, // Read last AO activity JSONL entry
checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap)
getActivityFallbackState, // Last-resort fallback: entry state + age-based decay
checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL
getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay
recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append)
classifyTerminalActivity, // Classify terminal output via detectActivity
appendActivityEntry, // Low-level JSONL append
@ -460,7 +460,7 @@ import {
normalizeAgentPermissionMode, // Normalize permission mode strings
DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold
DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window
ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry
ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock
PREFERRED_GH_PATH, // /usr/local/bin/gh
CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants
type Session, type ProjectConfig, type RuntimeHandle,

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.

@ -1 +0,0 @@
Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1

View File

@ -11,19 +11,28 @@
* If not (common with nvm/fnm/volta), rebuilds from source via npx node-gyp.
* See: https://github.com/ComposioHQ/agent-orchestrator/issues/987
*
* 3. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web
* 3. Verifies better-sqlite3 has a native binding for this Node ABI.
* Node majors can ship new NODE_MODULE_VERSION values before better-sqlite3
* publishes matching prebuilds; global installs must rebuild from source.
* See: https://github.com/ComposioHQ/agent-orchestrator/issues/1822
*
* 4. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web
* after a version upgrade, so `ao start` serves fresh dashboard assets.
* Writes a version stamp (.next/AO_VERSION) to skip cleanup on subsequent runs.
*/
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function findPackageUp(startDir, ...segments) {
function isWindows() {
return process.platform === "win32";
}
export function findPackageUp(startDir, ...segments) {
let dir = resolve(startDir);
while (true) {
const candidate = resolve(dir, "node_modules", ...segments);
@ -35,12 +44,12 @@ function findPackageUp(startDir, ...segments) {
return null;
}
function resolveNodeModulesPackage(fromDir, ...segments) {
export function resolveNodeModulesPackage(fromDir, ...segments) {
const packageDir = resolve(fromDir, "node_modules", ...segments);
return existsSync(resolve(packageDir, "package.json")) ? packageDir : null;
}
function findWebDir() {
export function findWebDir() {
const directWebDir = findPackageUp(__dirname, "@aoagents", "ao-web");
if (directWebDir) return directWebDir;
@ -50,8 +59,112 @@ function findWebDir() {
return resolveNodeModulesPackage(cliDir, "@aoagents", "ao-web");
}
// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) ---
if (process.platform !== "win32") {
export function findBetterSqlite3Dir() {
const directBetterSqlite3Dir = findPackageUp(__dirname, "better-sqlite3");
if (directBetterSqlite3Dir) return directBetterSqlite3Dir;
const cliDir = findPackageUp(__dirname, "@aoagents", "ao-cli");
if (!cliDir) return null;
const coreDir = resolveNodeModulesPackage(cliDir, "@aoagents", "ao-core");
if (!coreDir) return null;
return (
resolveNodeModulesPackage(coreDir, "better-sqlite3") ?? findPackageUp(coreDir, "better-sqlite3")
);
}
export function betterSqlite3BindingCandidates(
packageDir,
{
platform = process.platform,
arch = process.arch,
modules = process.versions.modules,
nodeVersion = process.versions.node,
} = {},
) {
return [
resolve(packageDir, "build", "better_sqlite3.node"),
resolve(packageDir, "build", "Debug", "better_sqlite3.node"),
resolve(packageDir, "build", "Release", "better_sqlite3.node"),
resolve(packageDir, "out", "Debug", "better_sqlite3.node"),
resolve(packageDir, "Debug", "better_sqlite3.node"),
resolve(packageDir, "out", "Release", "better_sqlite3.node"),
resolve(packageDir, "Release", "better_sqlite3.node"),
resolve(packageDir, "build", "default", "better_sqlite3.node"),
resolve(packageDir, "compiled", nodeVersion, platform, arch, "better_sqlite3.node"),
resolve(packageDir, "addon-build", "release", "install-root", "better_sqlite3.node"),
resolve(packageDir, "addon-build", "debug", "install-root", "better_sqlite3.node"),
resolve(packageDir, "addon-build", "default", "install-root", "better_sqlite3.node"),
resolve(
packageDir,
"lib",
"binding",
`node-v${modules}-${platform}-${arch}`,
"better_sqlite3.node",
),
];
}
export function hasBetterSqlite3Binding(packageDir, options = {}) {
const fileExists = options.existsSync ?? existsSync;
return betterSqlite3BindingCandidates(packageDir, options).some((candidate) =>
fileExists(candidate),
);
}
export function betterSqlite3RebuildCommand(packageDir, env = process.env) {
const packageManager =
`${env.npm_config_user_agent ?? ""} ${env.npm_execpath ?? ""}`.toLowerCase();
if (packageManager.includes("npm") && !packageManager.includes("pnpm")) {
return { command: "npm", args: ["rebuild"], display: `cd ${packageDir} && npm rebuild` };
}
return {
command: "pnpm",
args: ["--dir", packageDir, "rebuild"],
display: `pnpm --dir ${packageDir} rebuild`,
};
}
function checkBetterSqlite3Binding() {
const betterSqlite3Dir = findBetterSqlite3Dir();
if (!betterSqlite3Dir) {
console.warn(
"⚠️ better-sqlite3 package not found; skipping activity-events native binding check",
);
return;
}
const abi = process.versions.modules;
if (hasBetterSqlite3Binding(betterSqlite3Dir)) {
console.log(
`✓ better-sqlite3 native binding present for Node ${process.version} (ABI v${abi})`,
);
return;
}
const { command, args, display } = betterSqlite3RebuildCommand(betterSqlite3Dir);
try {
execFileSync(command, args, {
cwd: betterSqlite3Dir,
stdio: "ignore",
timeout: 120000,
shell: isWindows(),
windowsHide: true,
});
console.log(
`✓ better-sqlite3 native binding rebuilt for Node ${process.version} (ABI v${abi})`,
);
} catch {
console.warn(
`⚠️ better-sqlite3 rebuild failed for Node ${process.version} (ABI v${abi}) — activity events may be unavailable. Manual fix: ${display}`,
);
}
}
function fixNodePty() {
if (isWindows()) return;
const nodePtyDir = findPackageUp(__dirname, "node-pty");
if (nodePtyDir) {
const spawnHelper = resolve(
@ -84,7 +197,11 @@ if (process.platform !== "win32") {
},
);
} catch {
console.log("⚠️ node-pty prebuilt binary incompatible with Node.js " + process.version + ", rebuilding...");
console.log(
"⚠️ node-pty prebuilt binary incompatible with Node.js " +
process.version +
", rebuilding...",
);
try {
execSync("npx --yes node-gyp rebuild", {
cwd: nodePtyDir,
@ -100,27 +217,43 @@ if (process.platform !== "win32") {
}
}
// --- 3. Clear stale Next.js runtime cache after version upgrade ---
try {
const webDir = findWebDir();
if (webDir) {
const pkgPath = resolve(webDir, "package.json");
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const version = pkg.version;
const cacheDir = resolve(webDir, ".next", "cache");
const stampPath = resolve(webDir, ".next", "AO_VERSION");
function clearDashboardCache() {
try {
const webDir = findWebDir();
if (webDir) {
const pkgPath = resolve(webDir, "package.json");
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const version = pkg.version;
const cacheDir = resolve(webDir, ".next", "cache");
const stampPath = resolve(webDir, ".next", "AO_VERSION");
if (existsSync(cacheDir)) {
rmSync(cacheDir, { recursive: true, force: true });
console.log("✓ Cleared stale .next/cache");
}
if (existsSync(resolve(webDir, ".next"))) {
writeFileSync(stampPath, version, "utf8");
console.log(`✓ Dashboard version stamp set to ${version}`);
if (existsSync(cacheDir)) {
rmSync(cacheDir, { recursive: true, force: true });
console.log("✓ Cleared stale .next/cache");
}
if (existsSync(resolve(webDir, ".next"))) {
writeFileSync(stampPath, version, "utf8");
console.log(`✓ Dashboard version stamp set to ${version}`);
}
}
}
} catch (err) {
console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`);
}
} catch (err) {
console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`);
}
export function runPostinstall() {
// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) ---
fixNodePty();
// --- 3. Ensure better-sqlite3 has a native binding for this Node ABI ---
checkBetterSqlite3Binding();
// --- 4. Clear stale Next.js runtime cache after version upgrade ---
clearDashboardCache();
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
runPostinstall();
}

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

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

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

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

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
betterSqlite3BindingCandidates,
betterSqlite3RebuildCommand,
hasBetterSqlite3Binding,
} from "../../../ao/bin/postinstall.js";
describe("ao postinstall better-sqlite3 native binding detection", () => {
const env = {
platform: "darwin",
arch: "arm64",
modules: "141",
nodeVersion: "25.9.0",
};
it("checks the current Node ABI binding path", () => {
const packageDir = "virtual-better-sqlite3";
const candidates = betterSqlite3BindingCandidates(packageDir, env);
expect(candidates.some((candidate) => candidate.includes("node-v141-darwin-arm64"))).toBe(true);
});
it("reports the binding present when a mocked candidate exists", () => {
const packageDir = "virtual-better-sqlite3";
const candidates = betterSqlite3BindingCandidates(packageDir, env);
const currentAbiBinding = candidates.find((candidate) =>
candidate.includes("node-v141-darwin-arm64"),
);
if (!currentAbiBinding) {
throw new Error("expected current ABI binding candidate");
}
const existingFiles = new Set([currentAbiBinding]);
expect(
hasBetterSqlite3Binding(packageDir, {
...env,
existsSync: (candidate: string) => existingFiles.has(candidate),
}),
).toBe(true);
});
it("reports the binding missing when no mocked candidate exists", () => {
expect(
hasBetterSqlite3Binding("virtual-better-sqlite3", {
...env,
existsSync: () => false,
}),
).toBe(false);
});
it("uses pnpm to rebuild inside the resolved better-sqlite3 package", () => {
expect(
betterSqlite3RebuildCommand("virtual-better-sqlite3", {
npm_config_user_agent: "pnpm/9.15.4 npm/? node/v25.9.0 darwin arm64",
}),
).toEqual({
command: "pnpm",
args: ["--dir", "virtual-better-sqlite3", "rebuild"],
display: "pnpm --dir virtual-better-sqlite3 rebuild",
});
});
});

View File

@ -34,6 +34,7 @@
},
"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:*",
@ -41,6 +42,7 @@
"@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 +59,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

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

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

@ -36,6 +36,7 @@ import {
findPidByPort,
killProcessTree,
loadLocalProjectConfigDetailed,
recordActivityEvent,
registerProjectInGlobalConfig,
getGlobalConfigPath,
type OrchestratorConfig,
@ -120,6 +121,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") {
@ -170,6 +182,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;
}
@ -958,10 +979,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 },
);
@ -971,14 +1003,25 @@ 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 },
);
@ -992,12 +1035,21 @@ async function runStartup(
if (lastStop && lastStop.sessionIds.length > 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 : [];
@ -1023,6 +1075,17 @@ async function runStartup(
if (allRestoreSessions.length > 0) {
const shouldRestore = 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) {
@ -1047,11 +1110,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)`,
@ -1104,7 +1189,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
}
}
@ -1380,6 +1473,21 @@ export function registerStart(program: Command): void {
reapOrphans?: 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 => {
@ -1512,6 +1620,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;
@ -1671,6 +1786,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 {
@ -1742,6 +1869,17 @@ export function registerStop(program: Command): void {
.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 } = {}) => {
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();
@ -1818,6 +1956,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)}`,
);
@ -1862,12 +2009,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(
@ -1894,7 +2077,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();
@ -1914,6 +2119,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

@ -19,6 +19,9 @@ import {
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,6 +110,21 @@ 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,
@ -306,6 +342,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 +480,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,6 +494,11 @@ 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;
@ -429,6 +512,7 @@ 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;
@ -462,6 +546,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 +555,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 +582,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";
@ -83,6 +84,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
@ -225,6 +234,13 @@ 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 },
});
process.exit(exitCode);
}
invalidateCache();
@ -233,6 +249,13 @@ async function handleGitUpdate(opts: {
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. " +
@ -243,6 +266,16 @@ async function handleGitUpdate(opts: {
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));
process.exit(1);
}
@ -260,9 +293,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);
}
@ -369,6 +427,13 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
invalidateCache();
console.log(chalk.green("\nUpdate complete."));
} else {
recordActivityEvent({
source: "cli",
kind: "cli.update_failed",
level: "error",
summary: `ao update (${method}) failed: install command exited non-zero`,
data: { method, command, exitCode },
});
process.exit(exitCode);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,10 @@ 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.
@ -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.
@ -143,7 +187,7 @@ notificationRouting:
# 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,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

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

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

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

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

@ -91,7 +91,7 @@
"zod": "^3.24.0"
},
"optionalDependencies": {
"better-sqlite3": "^11.0.0"
"better-sqlite3": "^12.10.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.3.0",
@ -99,8 +99,8 @@
"@types/node": "^25.2.3",
"@vitest/coverage-v8": "^4.0.18",
"rollup": "^4.60.1",
"tsx": "^4.21.0",
"tslib": "^2.8.1",
"tsx": "^4.21.0",
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},

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

@ -9,9 +9,26 @@ import {
appendActivityEntry,
recordTerminalActivity,
getActivityLogPath,
ACTIVITY_INPUT_STALENESS_MS,
getActivityFallbackState,
} from "../activity-log.js";
import type { ActivityState } from "../types.js";
import type { ActivityDetection, ActivityLogEntry, ActivityState } from "../types.js";
const minutesAgo = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString();
const toActivityResult = (
entry: ActivityLogEntry,
): { entry: ActivityLogEntry; modifiedAt: Date } => ({
entry,
modifiedAt: new Date(entry.ts),
});
const detectWithProcessCheck = (
isProcessRunning: boolean,
activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null,
): ActivityDetection | null => {
if (!isProcessRunning) return { state: "exited", timestamp: new Date() };
return checkActivityLogState(activityResult) ?? getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
};
describe("classifyTerminalActivity", () => {
it("returns active state with no trigger", () => {
@ -56,13 +73,20 @@ describe("checkActivityLogState", () => {
expect(result?.state).toBe("blocked");
});
it("returns null for stale waiting_input entry", () => {
const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString();
it("returns waiting_input even when older than the former wallclock cap", () => {
const result = checkActivityLogState({
entry: { ts: staleTs, state: "waiting_input", source: "terminal" },
entry: { ts: minutesAgo(10), state: "waiting_input", source: "terminal" },
modifiedAt: new Date(),
});
expect(result).toBeNull();
expect(result?.state).toBe("waiting_input");
});
it("returns blocked even when older than the former wallclock cap", () => {
const result = checkActivityLogState({
entry: { ts: minutesAgo(6), state: "blocked", source: "terminal" },
modifiedAt: new Date(),
});
expect(result?.state).toBe("blocked");
});
it("returns null for non-critical states", () => {
@ -82,6 +106,77 @@ describe("checkActivityLogState", () => {
});
});
describe("getActivityFallbackState", () => {
it("returns waiting_input for a 10-minute-old entry instead of decaying to idle", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(10), state: "waiting_input", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("waiting_input");
});
it("returns blocked for a 6-minute-old entry instead of decaying to idle", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(6), state: "blocked", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("blocked");
});
it("returns blocked for a 1-minute-old entry with unchanged behavior", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(1), state: "blocked", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("blocked");
});
it("lets a newer active entry override an older waiting_input entry", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "ao-test-"));
try {
await mkdir(join(tmpDir, ".ao"), { recursive: true });
const waitingEntry: ActivityLogEntry = {
ts: minutesAgo(6),
state: "waiting_input",
source: "terminal",
};
const activeEntry: ActivityLogEntry = {
ts: new Date(Date.now() - 1000).toISOString(),
state: "active",
source: "terminal",
};
await writeFile(
getActivityLogPath(tmpDir),
`${JSON.stringify(waitingEntry)}\n${JSON.stringify(activeEntry)}\n`,
"utf-8",
);
const activityResult = await readLastActivityEntry(tmpDir);
const result = getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
expect(activityResult?.entry.state).toBe("active");
expect(result?.state).toBe("active");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("returns exited when the process check fails before a stale waiting_input can fall through", () => {
const result = detectWithProcessCheck(
false,
toActivityResult({ ts: minutesAgo(6), state: "waiting_input", source: "terminal" }),
);
expect(result?.state).toBe("exited");
});
});
describe("readLastActivityEntry", () => {
let tmpDir: string;
@ -144,6 +239,25 @@ describe("readLastActivityEntry", () => {
const result = await readLastActivityEntry(tmpDir);
expect(result).toBeNull();
});
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 = {
ts: minutesAgo(10),
state: "waiting_input",
source: "terminal",
trigger: "approve?",
};
await writeFile(
getActivityLogPath(tmpDir),
`${JSON.stringify(completeEntry)}\n{"ts":"${new Date().toISOString()}","state":`,
"utf-8",
);
const result = await readLastActivityEntry(tmpDir);
expect(result?.entry).toEqual(completeEntry);
});
});
describe("recordTerminalActivity", () => {

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

@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActivityEventsDbWarningForTests,
emitActivityEventsDbUnavailableWarning,
formatActivityEventsDbUnavailableWarning,
} from "../events-db.js";
describe("activity-events DB unavailable warning", () => {
const originalArgv = process.argv;
const originalDebug = process.env["AO_DEBUG"];
beforeEach(() => {
__resetActivityEventsDbWarningForTests();
vi.spyOn(console, "warn").mockImplementation(() => {});
delete process.env["AO_DEBUG"];
process.argv = ["node", "ao"];
});
afterEach(() => {
process.argv = originalArgv;
if (originalDebug === undefined) {
delete process.env["AO_DEBUG"];
} else {
process.env["AO_DEBUG"] = originalDebug;
}
vi.restoreAllMocks();
});
it("formats missing native binding errors without the bindings search path", () => {
const message = formatActivityEventsDbUnavailableWarning(
new Error(
"Could not locate the bindings file. Tried:\n → /tmp/build/Release/better_sqlite3.node",
),
);
expect(message).toBe(
`[ao] activity-events disabled: better-sqlite3 not compiled for Node ${process.version} (ABI v${process.versions.modules}). Run \`pnpm rebuild better-sqlite3\` or use a supported Node version.`,
);
expect(message).not.toContain("Tried:");
expect(message).not.toContain("/tmp/build/Release");
});
it("prints the runtime warning once per process", () => {
process.env["AO_DEBUG"] = "1";
const err = new Error(
"Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node",
);
emitActivityEventsDbUnavailableWarning(err);
emitActivityEventsDbUnavailableWarning(err);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(vi.mocked(console.warn).mock.calls[0]?.[0]).toContain(
"activity-events disabled: better-sqlite3 not compiled",
);
});
it("suppresses non-events invocations unless AO_DEBUG=1", () => {
const err = new Error(
"Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node",
);
process.argv = ["node", "ao", "spawn", "demo"];
emitActivityEventsDbUnavailableWarning(err);
expect(console.warn).not.toHaveBeenCalled();
process.argv = ["node", "ao", "events", "stats"];
emitActivityEventsDbUnavailableWarning(err);
expect(console.warn).toHaveBeenCalledTimes(1);
});
});

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

View File

@ -1770,7 +1770,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 +1871,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 +2844,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 +3455,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 +3524,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 +3541,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 +3592,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 +3773,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 +4355,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 +4407,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 +4457,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

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

@ -348,6 +348,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 +489,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

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

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

View File

@ -8,6 +8,7 @@ import { createSessionManager } from "../../session-manager.js";
import {
writeMetadata,
readMetadataRaw,
updateMetadata,
} from "../../metadata.js";
import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js";
import type {
@ -63,6 +64,47 @@ describe("list", () => {
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
});
it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: config.projects["my-app"]!.path,
branch: "feat/a",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
updateMetadata(sessionsDir, "app-1", { codexThreadId: "thread-1" });
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const agentWithSessionInfo: Agent = {
...mockAgent,
name: "codex",
getSessionInfo: vi.fn().mockResolvedValue({
summary: null,
agentSessionId: "rollout-1",
metadata: { codexThreadId: "thread-1" },
}),
};
const registryWithDeadRuntime: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return agentWithSessionInfo;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
await sm.list("my-app");
await sm.list("my-app");
expect(agentWithSessionInfo.getSessionInfo).not.toHaveBeenCalled();
expect(readMetadataRaw(sessionsDir, "app-1")!["codexThreadId"]).toBe("thread-1");
});
it("does not backfill role onto foreign bare-id orchestrator records (issue #1048)", async () => {
// Regression guard for PR #1075 review comment: a legacy record whose id
// is `{projectId}-orchestrator` (pre-numbered scheme, wrong prefix) must
@ -220,7 +262,9 @@ describe("list", () => {
const sm = createSessionManager({ config, registry: registryWithDead });
const sessions = await sm.list();
expect(sessions[0].status).toBe("killed");
// sm.list() persists "detecting" (not "terminated") so the lifecycle
// manager's probe pipeline makes the final terminal decision (#1735).
expect(sessions[0].status).toBe("detecting");
expect(sessions[0].activity).toBe("exited");
});
@ -336,7 +380,8 @@ describe("list", () => {
expect(sessions).toHaveLength(1);
expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName);
expect(sessions[0].status).toBe("killed");
// sm.list() persists "detecting" so the lifecycle manager decides (#1735).
expect(sessions[0].status).toBe("detecting");
expect(sessions[0].activity).toBe("exited");
expect(agentWithSpy.getActivityState).not.toHaveBeenCalled();
});

View File

@ -575,7 +575,7 @@ describe("restore", () => {
expect(meta!["restoreFallbackReason"]).toBe("mock-agent.getRestoreCommand returned null");
});
it("does not launch a fresh chat when a native-restore agent cannot build restore command", async () => {
it("falls back to a fresh launch when a native-restore agent cannot build restore command", async () => {
const wsPath = join(tmpDir, "ws-app-native-restore-missing");
mkdirSync(wsPath, { recursive: true });
@ -605,12 +605,119 @@ describe("restore", () => {
const sm = createSessionManager({ config, registry: registryWithNativeRestoreAgent });
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
expect(mockRuntime.create).not.toHaveBeenCalled();
await sm.restore("app-1");
expect(mockRuntime.create).toHaveBeenCalled();
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.launchCommand).toBe("mock-agent --start");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta!["restoreFallbackReason"]).toBe("codex.getRestoreCommand returned null");
});
it("persists native restore metadata even when runtime is already dead", async () => {
const wsPath = join(tmpDir, "ws-app-dead-runtime-metadata");
mkdirSync(wsPath, { recursive: true });
const deadRuntime: Runtime = {
...mockRuntime,
isAlive: vi.fn().mockResolvedValue(false),
};
const agentWithDiscoverableThread: Agent = {
...mockAgent,
name: "codex",
getSessionInfo: vi.fn().mockResolvedValue({
summary: null,
agentSessionId: "rollout-1",
metadata: { codexThreadId: "thread-1" },
}),
getRestoreCommand: vi
.fn()
.mockImplementation(async (session) =>
session.metadata?.codexThreadId ? `codex resume ${session.metadata.codexThreadId}` : null,
),
};
const registryWithDeadRuntime: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return deadRuntime;
if (slot === "agent") return agentWithDiscoverableThread;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: wsPath,
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
const restored = await sm.restore("app-1");
expect(agentWithDiscoverableThread.getSessionInfo).toHaveBeenCalled();
expect(agentWithDiscoverableThread.getRestoreCommand).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ codexThreadId: "thread-1" }),
}),
expect.any(Object),
);
expect(restored.metadata["codexThreadId"]).toBe("thread-1");
const createCall = (deadRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.launchCommand).toBe("codex resume thread-1");
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta!["codexThreadId"]).toBe("thread-1");
});
it("uses project path as restore workspace when worktree metadata is missing", async () => {
mkdirSync(config.projects["my-app"]!.path, { recursive: true });
const agentWithWorkspaceAssertion: Agent = {
...mockAgent,
name: "codex",
getRestoreCommand: vi
.fn()
.mockImplementation(async (session) =>
session.workspacePath === config.projects["my-app"]!.path
? "codex resume thread-1"
: null,
),
};
const registryWithWorkspaceAssertion: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return agentWithWorkspaceAssertion;
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: "",
branch: "feat/TEST-1",
status: "killed",
project: "my-app",
runtimeHandle: makeHandle("rt-old"),
});
const sm = createSessionManager({ config, registry: registryWithWorkspaceAssertion });
const restored = await sm.restore("app-1");
expect(restored.workspacePath).toBe(config.projects["my-app"]!.path);
expect(agentWithWorkspaceAssertion.getRestoreCommand).toHaveBeenCalledWith(
expect.objectContaining({ workspacePath: config.projects["my-app"]!.path }),
expect.any(Object),
);
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(createCall.workspacePath).toBe(config.projects["my-app"]!.path);
expect(createCall.launchCommand).toBe("codex resume thread-1");
});
it("clears restore fallback reason when getRestoreCommand succeeds", async () => {
const wsPath = join(tmpDir, "ws-app-restore-clears-fallback");
mkdirSync(wsPath, { recursive: true });

View File

@ -13,7 +13,7 @@ import {
readMetadata,
readMetadataRaw,
} from "../../metadata.js";
import { getProjectWorktreesDir } from "../../paths.js";
import { getProjectDir, getProjectWorktreesDir } from "../../paths.js";
import type {
OrchestratorConfig,
PluginRegistry,
@ -2489,4 +2489,183 @@ describe("spawn", () => {
});
});
describe("relaunchOrchestrator", () => {
it("spawns a fresh orchestrator when none exists", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
expect(session.id).toBe("app-orchestrator");
expect(session.status).toBe("working");
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
});
it("kills and replaces an existing orchestrator regardless of strategy", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
expect(session.id).toBe("app-orchestrator");
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
expect(mockWorkspace.create).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "app-orchestrator" }),
);
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
expect(meta?.["status"]).toBe("working");
expect(meta?.["runtimeHandle"]).not.toContain("old-rt");
});
it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => {
const configWithIgnore: OrchestratorConfig = {
...config,
projects: {
...config.projects,
"my-app": {
...config.projects["my-app"]!,
orchestratorSessionStrategy: "ignore",
},
},
};
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry });
await sm.relaunchOrchestrator({ projectId: "my-app" });
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
expect(mockWorkspace.create).toHaveBeenCalled();
});
it("coalesces concurrent relaunch calls into a single replacement", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
const [s1, s2] = await Promise.all([
sm.relaunchOrchestrator({ projectId: "my-app" }),
sm.relaunchOrchestrator({ projectId: "my-app" }),
]);
expect(s1.id).toBe("app-orchestrator");
expect(s2.id).toBe("app-orchestrator");
expect(mockRuntime.destroy).toHaveBeenCalledTimes(1);
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
});
it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
let releaseWorkspace: () => void = () => {};
const blockingWorkspace = new Promise<void>((resolve) => {
releaseWorkspace = resolve;
});
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
await blockingWorkspace;
return {
path: join(tmpDir, "ws-relaunch"),
branch: cfg.branch,
sessionId: cfg.sessionId,
projectId: cfg.projectId,
};
});
const sm = createSessionManager({ config, registry: mockRegistry });
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
await Promise.resolve();
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
releaseWorkspace();
const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]);
expect(relaunched.id).toBe("app-orchestrator");
expect(ensured.id).toBe("app-orchestrator");
// Both should resolve to the *post-relaunch* session, not the killed one.
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
});
it("waits for an in-flight ensureOrchestrator before replacing", async () => {
let releaseWorkspace: () => void = () => {};
const blockingWorkspace = new Promise<void>((resolve) => {
releaseWorkspace = resolve;
});
const ensureWorkspacePath = join(tmpDir, "ws-ensure");
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
await blockingWorkspace;
return {
path: ensureWorkspacePath,
branch: cfg.branch,
sessionId: cfg.sessionId,
projectId: cfg.projectId,
};
});
const sm = createSessionManager({ config, registry: mockRegistry });
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
// Yield so ensure can register itself in the in-flight map.
await Promise.resolve();
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
releaseWorkspace();
const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]);
expect(ensured.id).toBe("app-orchestrator");
expect(relaunched.id).toBe("app-orchestrator");
// Relaunch's kill must run, destroying the runtime ensure just spawned.
expect(mockRuntime.destroy).toHaveBeenCalled();
});
it("rewrites the orchestrator-prompt file with the new system prompt", async () => {
writeMetadata(sessionsDir, "app-orchestrator", {
role: "orchestrator",
project: "my-app",
status: "working",
branch: "orchestrator/app-orchestrator",
worktree: join(tmpDir, "old-orchestrator-ws"),
runtimeHandle: makeHandle("old-rt"),
});
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.relaunchOrchestrator({
projectId: "my-app",
systemPrompt: "FRESH ORCHESTRATOR PROMPT",
});
const promptPath = join(
getProjectDir("my-app"),
"orchestrator-prompt-app-orchestrator.md",
);
expect(existsSync(promptPath)).toBe(true);
expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT");
});
});
});

View File

@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager {
.mockResolvedValue(
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
),
relaunchOrchestrator: vi
.fn()
.mockResolvedValue(
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
),
restore: vi.fn().mockResolvedValue(makeSession()),
list: vi.fn().mockResolvedValue([]),
listCached: vi.fn().mockResolvedValue([]),

View File

@ -116,6 +116,33 @@ describe("readLastJsonlEntry", () => {
expect(result!.lastType).toBe("x");
expect(result!.payloadType).toBeNull();
});
it("extracts top-level subtype and level (Claude system-entry shape)", async () => {
// Real Claude writes API errors as {"type":"system","subtype":"api_error",
// "level":"error","cause":{...}}. Consumers (e.g. claude-code plugin's
// getClaudeActivityState) need both fields to classify activity correctly.
const path = setup(
'{"type":"system","subtype":"api_error","level":"error","cause":{"code":"ConnectionRefused"}}\n',
);
const result = await readLastJsonlEntry(path);
expect(result!.lastType).toBe("system");
expect(result!.lastSubtype).toBe("api_error");
expect(result!.lastLevel).toBe("error");
});
it("returns lastSubtype/lastLevel null when fields are absent", async () => {
const path = setup('{"type":"assistant","message":"hello"}\n');
const result = await readLastJsonlEntry(path);
expect(result!.lastSubtype).toBeNull();
expect(result!.lastLevel).toBeNull();
});
it("returns lastSubtype/lastLevel null when fields are non-string", async () => {
const path = setup('{"type":"x","subtype":42,"level":{"nested":true}}\n');
const result = await readLastJsonlEntry(path);
expect(result!.lastSubtype).toBeNull();
expect(result!.lastLevel).toBeNull();
});
});
describe("isGitBranchNameSafe", () => {

View File

@ -20,14 +20,40 @@ export type ActivityEventSource =
| "scm"
| "runtime"
| "agent"
| "tracker"
| "workspace"
| "notifier"
| "reaction"
| "report-watcher";
| "report-watcher"
| "cli"
| "config"
| "plugin-registry"
| "migration"
| "recovery";
export type ActivityEventKind =
| "session.spawn_started"
| "session.spawned"
| "session.spawn_failed"
| "session.spawn_step_failed"
| "session.killed"
| "session.kill_started"
| "session.send_failed"
| "session.restore_failed"
| "session.restore_fallback"
| "session.rollback_started"
| "session.rollback_step_failed"
| "session.workspace_hooks_failed"
| "session.cleanup_error"
| "session.orchestrator_conflict"
| "runtime.lost_detected"
| "runtime.lost_persist_failed"
| "runtime.destroy_failed"
| "workspace.destroy_failed"
| "agent.opencode_purge_failed"
| "tracker.issue_fetch_failed"
| "tracker.generate_prompt_failed"
| "metadata.corrupt_detected"
| "activity.transition"
| "lifecycle.transition"
| "ci.failing"
@ -41,6 +67,20 @@ export type ActivityEventKind =
| "runtime.probe_failed"
| "agent.process_probe_failed"
| "agent.activity_probe_failed"
// Plugin-internal failure shapes (issue #1659)
| "scm.gh_unavailable"
| "scm.batch_enrich_pr_failed"
| "scm.ci_summary_failclosed"
| "workspace.post_create_failed"
| "workspace.branch_collision"
| "workspace.destroy_fell_back"
| "workspace.corrupt_clone_skipped"
| "tracker.dep_missing"
| "tracker.api_timeout"
| "notifier.auth_failed"
| "notifier.unreachable"
| "notifier.rate_limited"
| "notifier.dep_missing"
// Reaction lifecycle
| "reaction.escalated"
| "reaction.send_to_agent_failed"
@ -51,8 +91,42 @@ export type ActivityEventKind =
| "session.auto_cleanup_failed"
| "lifecycle.poll_failed"
| "detecting.escalated"
// Notification delivery
| "notification.delivery_failed"
| "notification.target_missing"
// Report watcher
| "report_watcher.triggered";
| "report_watcher.triggered"
// Config/plugin-registry/storage migration
| "config.project_resolve_failed"
| "config.project_malformed"
| "config.project_invalid"
| "config.migrated"
| "plugin-registry.load_failed"
| "plugin-registry.validation_failed"
| "plugin-registry.specifier_failed"
| "migration.blocked"
| "migration.project_failed"
| "migration.rename_failed"
| "migration.completed"
| "migration.rollback_skipped"
// Webhook ingress (api source)
| "api.webhook_unverified"
| "api.webhook_rejected"
| "api.webhook_received"
| "api.webhook_failed"
// WebSocket terminal mux (ui source — Node-side server only)
| "ui.terminal_connected"
| "ui.terminal_disconnected"
| "ui.terminal_heartbeat_lost"
| "ui.terminal_pty_lost"
| "ui.terminal_protocol_error"
| "ui.session_broadcast_failed"
// Recovery/forensic instrumentation
| "recovery.session_failed"
| "recovery.action_failed"
| "api.agent_report.session_not_found"
| "api.agent_report.transition_rejected"
| "api.agent_report.apply_failed";
export type ActivityEventLevel = "debug" | "info" | "warn" | "error";
@ -92,14 +166,12 @@ export function droppedEventCount(): number {
}
function pruneOldEvents(db: ReturnType<typeof getDb>, cutoff: number): void {
db
?.prepare(
`DELETE FROM activity_events
db?.prepare(
`DELETE FROM activity_events
WHERE rowid IN (
SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ?
)`,
)
.run(cutoff, PRUNE_BATCH_SIZE);
).run(cutoff, PRUNE_BATCH_SIZE);
}
// Patterns that indicate sensitive field names
@ -116,7 +188,10 @@ function redactCredentialUrls(input: string): string {
const proto = result.indexOf("://", offset);
if (proto === -1) break;
// Only match http:// or https:// (case-insensitive, matching old /gi flag)
if (proto < 4) { offset = proto + 3; continue; }
if (proto < 4) {
offset = proto + 3;
continue;
}
const schemeEnd = result.slice(Math.max(0, proto - 5), proto).toLowerCase();
if (!schemeEnd.endsWith("http") && !schemeEnd.endsWith("https")) {
offset = proto + 3;
@ -127,7 +202,7 @@ function redactCredentialUrls(input: string): string {
while (cursor < result.length) {
const ch = result.charCodeAt(cursor);
// Space/control chars or '/' mean no '@' is coming in userinfo
if (ch <= 0x20 || ch === 0x2F) break;
if (ch <= 0x20 || ch === 0x2f) break;
if (ch === 0x40) {
// '@' found — redact everything between :// and @
// Lowercase the scheme to match the old /gi regex behavior
@ -140,7 +215,11 @@ function redactCredentialUrls(input: string): string {
cursor++;
}
// No '@' found — not a credential URL, move past this ://
if (cursor >= result.length || result.charCodeAt(cursor) <= 0x20 || result.charCodeAt(cursor) === 0x2F) {
if (
cursor >= result.length ||
result.charCodeAt(cursor) <= 0x20 ||
result.charCodeAt(cursor) === 0x2f
) {
offset = proto + 3;
}
}

View File

@ -15,10 +15,8 @@ import { join, dirname } from "node:path";
import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js";
/**
* Maximum age (ms) for `waiting_input`/`blocked` entries before they're
* considered stale. If no new terminal output overwrites the entry within
* this window, the state falls through to downstream fallbacks instead of
* keeping the session stuck in `needs_input` on the dashboard forever.
* @deprecated Actionable states no longer decay on wallclock. Retained until
* the activity-reducer cleanup removes the old activity-log module.
*/
export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes
@ -129,7 +127,7 @@ export async function readLastActivityEntry(
/**
* Check the AO activity JSONL for actionable states only.
*
* Only returns `waiting_input`/`blocked` (with a staleness cap).
* Only returns `waiting_input`/`blocked`.
* Non-critical states (`active`, `ready`, `idle`) always return `null` so
* callers fall through to their native signals (git commits, chat history,
* API queries, native JSONL). This prevents the lifecycle manager's
@ -144,18 +142,12 @@ export function checkActivityLogState(
const { entry } = activityResult;
if (entry.state === "waiting_input" || entry.state === "blocked") {
// Use the entry's own timestamp for staleness — not file mtime, which
// gets refreshed every poll cycle by recordActivity and would prevent
// stale entries from ever being detected.
const entryTs = new Date(entry.ts);
if (Number.isNaN(entryTs.getTime())) return null;
const ageMs = Date.now() - entryTs.getTime();
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
return { state: entry.state, timestamp: entryTs };
}
return { state: entry.state, timestamp: entryTs };
}
// Non-critical states and stale entries — fall through to native signals
// Non-critical states fall through to native signals
return null;
}
@ -178,16 +170,8 @@ export function getActivityFallbackState(
const entryTs = new Date(entry.ts);
if (Number.isNaN(entryTs.getTime())) return null;
// Actionable states use the same staleness cap as checkActivityLogState.
// If the entry is stale, fall through to age-based decay instead of
// re-surfacing a waiting_input/blocked that checkActivityLogState already filtered.
if (entry.state === "waiting_input" || entry.state === "blocked") {
const ageMs = Date.now() - entryTs.getTime();
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
return { state: entry.state, timestamp: entryTs };
}
// Stale actionable entry — treat as idle
return { state: "idle", timestamp: entryTs };
return { state: entry.state, timestamp: entryTs };
}
// Age-based decay: active→ready→idle, but never promote past the

View File

@ -18,7 +18,7 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { basename, dirname, join } from "node:path";
import type {
CanonicalSessionLifecycle,
CanonicalSessionReason,
@ -26,8 +26,14 @@ import type {
SessionId,
SessionStatus,
} from "./types.js";
import { recordActivityEvent } from "./activity-events.js";
import { mutateMetadata, readMetadataRaw } from "./metadata.js";
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js";
import {
buildLifecycleMetadataPatch,
cloneLifecycle,
deriveLegacyStatus,
parseCanonicalLifecycle,
} from "./lifecycle-state.js";
import { parsePrFromUrl } from "./utils/pr.js";
import { assertValidSessionIdComponent } from "./utils/session-id.js";
import { validateStatus } from "./utils/validation.js";
@ -254,6 +260,11 @@ function buildAuditDir(dataDir: string): string {
return join(dataDir, ".agent-report-audit");
}
function inferProjectIdFromDataDir(dataDir: string): string | undefined {
// dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering.
return basename(dirname(dataDir)) || undefined;
}
const AGENT_REPORT_AUDIT_MAX_BYTES = 256 * 1024;
const AGENT_REPORT_AUDIT_MAX_ENTRIES = 200;
@ -383,8 +394,22 @@ export function applyAgentReport(
sessionId: SessionId,
input: ApplyAgentReportInput,
): ApplyAgentReportResult {
const projectId = inferProjectIdFromDataDir(dataDir);
const raw = readMetadataRaw(dataDir, sessionId);
if (!raw) {
recordActivityEvent({
projectId,
sessionId,
source: "api",
kind: "api.agent_report.session_not_found",
level: "warn",
summary: `applyAgentReport: session not found: ${sessionId}`,
data: {
reportState: input.state,
actor: input.actor,
source: input.source,
},
});
throw new Error(`Session not found: ${sessionId}`);
}
@ -428,93 +453,124 @@ export function applyAgentReport(
let legacyStatus: SessionStatus | null = null;
let previousLegacyStatus: SessionStatus | null = null;
const nextMetadata = mutateMetadata(dataDir, sessionId, (existing) => {
const current = cloneLifecycle(
parseCanonicalLifecycle(existing, {
sessionId,
status: validateStatus(existing["status"]),
}),
);
previousLegacyStatus = deriveLegacyStatus(current);
before = buildAuditSnapshot(current, previousLegacyStatus);
const validation = validateAgentReportTransition(current, input.state);
if (!validation.ok) {
appendAgentReportAuditEntry(dataDir, sessionId, {
timestamp: now,
actor,
source,
reportState: input.state,
note: trimmedNote,
prNumber,
prUrl: trimmedPrUrl,
prIsDraft,
accepted: false,
rejectionReason: validation.reason ?? "transition rejected",
before,
after: before,
});
throw new Error(validation.reason ?? "transition rejected");
}
const mapped = mapAgentReportToLifecycle(input.state);
previousState = current.session.state;
nextState = mapped.sessionState;
current.session.state = mapped.sessionState;
current.session.reason = mapped.sessionReason;
current.session.lastTransitionAt = now;
if (isPRWorkflowReport(input.state)) {
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
const effectivePrNumber =
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
const canAdvancePrState =
effectivePrUrl !== undefined ||
effectivePrNumber !== undefined ||
current.pr.state !== "none";
if (canAdvancePrState) {
current.pr.state = "open";
current.pr.reason =
input.state === "ready_for_review" ? "review_pending" : "in_progress";
current.pr.lastObservedAt = now;
const nextMetadata = mutateMetadata(
dataDir,
sessionId,
(existing) => {
const current = cloneLifecycle(
parseCanonicalLifecycle(existing, {
sessionId,
status: validateStatus(existing["status"]),
}),
);
previousLegacyStatus = deriveLegacyStatus(current);
before = buildAuditSnapshot(current, previousLegacyStatus);
const validation = validateAgentReportTransition(current, input.state);
if (!validation.ok) {
const rejectionReason = validation.reason ?? "transition rejected";
appendAgentReportAuditEntry(dataDir, sessionId, {
timestamp: now,
actor,
source,
reportState: input.state,
note: trimmedNote,
prNumber,
prUrl: trimmedPrUrl,
prIsDraft,
accepted: false,
rejectionReason,
before,
after: before,
});
recordActivityEvent({
projectId,
sessionId,
source: "api",
kind: "api.agent_report.transition_rejected",
level: "warn",
summary: `applyAgentReport rejected ${input.state} for ${sessionId}: ${rejectionReason}`,
data: {
reportState: input.state,
rejectionReason,
actor,
reportSource: source,
fromState: current.session.state,
fromReason: current.session.reason,
legacyStatus: previousLegacyStatus,
},
});
throw new Error(rejectionReason);
}
if (effectivePrUrl) {
current.pr.url = effectivePrUrl;
const mapped = mapAgentReportToLifecycle(input.state);
previousState = current.session.state;
nextState = mapped.sessionState;
current.session.state = mapped.sessionState;
current.session.reason = mapped.sessionReason;
current.session.lastTransitionAt = now;
if (isPRWorkflowReport(input.state)) {
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
const effectivePrNumber =
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
const canAdvancePrState =
effectivePrUrl !== undefined ||
effectivePrNumber !== undefined ||
current.pr.state !== "none";
if (canAdvancePrState) {
current.pr.state = "open";
current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress";
current.pr.lastObservedAt = now;
}
if (effectivePrUrl) {
current.pr.url = effectivePrUrl;
}
if (effectivePrNumber !== undefined) {
current.pr.number = effectivePrNumber;
}
}
if (effectivePrNumber !== undefined) {
current.pr.number = effectivePrNumber;
if (mapped.sessionState === "working" && current.session.startedAt === null) {
current.session.startedAt = now;
}
}
if (mapped.sessionState === "working" && current.session.startedAt === null) {
current.session.startedAt = now;
}
legacyStatus = deriveLegacyStatus(current);
const next = { ...existing };
Object.assign(
next,
buildLifecycleMetadataPatch(current),
{
legacyStatus = deriveLegacyStatus(current);
const next = { ...existing };
Object.assign(next, buildLifecycleMetadataPatch(current), {
[AGENT_REPORT_METADATA_KEYS.STATE]: input.state,
[AGENT_REPORT_METADATA_KEYS.AT]: now,
},
);
if (trimmedNote) {
next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
} else {
next[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
}
if (isPRWorkflowReport(input.state)) {
if (trimmedPrUrl) {
next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
});
if (trimmedNote) {
next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
} else {
next[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
}
if (prNumber !== undefined) {
next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
if (isPRWorkflowReport(input.state)) {
if (trimmedPrUrl) {
next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
}
if (prNumber !== undefined) {
next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
}
if (prIsDraft !== undefined) {
next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
}
}
if (prIsDraft !== undefined) {
next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
}
}
return next;
});
return next;
},
{ activityEventSource: "api" },
);
if (!nextMetadata || !before || !previousState || !nextState || !legacyStatus) {
recordActivityEvent({
projectId,
sessionId,
source: "api",
kind: "api.agent_report.apply_failed",
level: "error",
summary: `failed to apply agent report ${input.state} for ${sessionId}`,
data: {
reportState: input.state,
actor,
source,
},
});
throw new Error(`Failed to apply agent report for session ${sessionId}`);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,504 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteFileSync } from "./atomic-write.js";
import { getProjectCodeReviewsDir } from "./paths.js";
export type CodeReviewRunStatus =
| "queued"
| "preparing"
| "running"
| "needs_triage"
| "sent_to_agent"
| "waiting_update"
| "clean"
| "outdated"
| "failed"
| "cancelled";
export type CodeReviewFindingStatus = "open" | "dismissed" | "sent_to_agent" | "resolved";
export type CodeReviewSeverity = "info" | "warning" | "error";
export interface CodeReviewRun {
id: string;
projectId: string;
linkedSessionId: string;
reviewerSessionId: string;
status: CodeReviewRunStatus;
createdAt: string;
updatedAt: string;
startedAt?: string;
completedAt?: string;
targetSha?: string;
baseSha?: string;
prNumber?: number;
prUrl?: string;
reviewerWorkspacePath?: string;
summary?: string;
terminationReason?: string;
}
export interface CodeReviewFinding {
id: string;
projectId: string;
runId: string;
linkedSessionId: string;
status: CodeReviewFindingStatus;
severity: CodeReviewSeverity;
title: string;
body: string;
filePath?: string;
startLine?: number;
endLine?: number;
category?: string;
confidence?: number;
fingerprint?: string;
createdAt: string;
updatedAt: string;
dismissedAt?: string;
dismissedBy?: string;
sentToAgentAt?: string;
}
export interface CodeReviewRunSummary extends CodeReviewRun {
findingCount: number;
openFindingCount: number;
dismissedFindingCount: number;
sentFindingCount: number;
resolvedFindingCount: number;
}
export interface CodeReviewStoreOptions {
/** Override storage dir for tests. Defaults to the project code review store path. */
storeDir?: string;
}
export interface ListCodeReviewRunsFilter {
linkedSessionId?: string;
status?: CodeReviewRunStatus;
}
export interface ListCodeReviewFindingsFilter {
runId?: string;
linkedSessionId?: string;
status?: CodeReviewFindingStatus;
}
export interface CreateCodeReviewRunInput {
linkedSessionId: string;
reviewerSessionId: string;
status?: CodeReviewRunStatus;
targetSha?: string;
baseSha?: string;
prNumber?: number;
prUrl?: string;
reviewerWorkspacePath?: string;
summary?: string;
}
export interface CreateCodeReviewFindingInput {
runId: string;
linkedSessionId: string;
severity: CodeReviewSeverity;
title: string;
body: string;
status?: CodeReviewFindingStatus;
filePath?: string;
startLine?: number;
endLine?: number;
category?: string;
confidence?: number;
fingerprint?: string;
}
const REVIEW_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
function assertSafeReviewId(id: string, label: string): void {
if (!id || id === "." || id === ".." || !REVIEW_ID_PATTERN.test(id)) {
throw new Error(`Unsafe ${label}: "${id}"`);
}
}
function normalizeIsoTimestamp(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function parseOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function parseRunStatus(value: unknown): CodeReviewRunStatus {
switch (value) {
case "queued":
case "preparing":
case "running":
case "needs_triage":
case "sent_to_agent":
case "waiting_update":
case "clean":
case "outdated":
case "failed":
case "cancelled":
return value;
default:
return "queued";
}
}
function parseFindingStatus(value: unknown): CodeReviewFindingStatus {
switch (value) {
case "dismissed":
case "sent_to_agent":
case "resolved":
return value;
case "open":
default:
return "open";
}
}
function parseSeverity(value: unknown): CodeReviewSeverity {
switch (value) {
case "error":
case "warning":
case "info":
return value;
default:
return "warning";
}
}
function readJsonFile(path: string): unknown | null {
try {
return JSON.parse(readFileSync(path, "utf-8")) as unknown;
} catch {
return null;
}
}
function writeJsonFile(path: string, value: unknown): void {
atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function removeUndefined<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
) as T;
}
function compareUpdatedDesc(
a: { updatedAt: string; createdAt: string; id: string },
b: { updatedAt: string; createdAt: string; id: string },
): number {
return (
Date.parse(b.updatedAt) - Date.parse(a.updatedAt) ||
Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
a.id.localeCompare(b.id)
);
}
function parseRun(projectId: string, value: unknown): CodeReviewRun | null {
if (!isRecord(value)) return null;
const id = parseOptionalString(value["id"]);
const linkedSessionId = parseOptionalString(value["linkedSessionId"]);
const reviewerSessionId = parseOptionalString(value["reviewerSessionId"]);
const createdAt = normalizeIsoTimestamp(value["createdAt"]);
const updatedAt = normalizeIsoTimestamp(value["updatedAt"]) ?? createdAt;
if (!id || !linkedSessionId || !reviewerSessionId || !createdAt || !updatedAt) return null;
return removeUndefined({
id,
projectId: parseOptionalString(value["projectId"]) ?? projectId,
linkedSessionId,
reviewerSessionId,
status: parseRunStatus(value["status"]),
createdAt,
updatedAt,
startedAt: normalizeIsoTimestamp(value["startedAt"]),
completedAt: normalizeIsoTimestamp(value["completedAt"]),
targetSha: parseOptionalString(value["targetSha"]),
baseSha: parseOptionalString(value["baseSha"]),
prNumber: parseNumber(value["prNumber"]),
prUrl: parseOptionalString(value["prUrl"]),
reviewerWorkspacePath: parseOptionalString(value["reviewerWorkspacePath"]),
summary: parseOptionalString(value["summary"]),
terminationReason: parseOptionalString(value["terminationReason"]),
});
}
function parseFinding(projectId: string, value: unknown): CodeReviewFinding | null {
if (!isRecord(value)) return null;
const id = parseOptionalString(value["id"]);
const runId = parseOptionalString(value["runId"]);
const linkedSessionId = parseOptionalString(value["linkedSessionId"]);
const title = parseOptionalString(value["title"]);
const body = parseOptionalString(value["body"]);
const createdAt = normalizeIsoTimestamp(value["createdAt"]);
const updatedAt = normalizeIsoTimestamp(value["updatedAt"]) ?? createdAt;
if (!id || !runId || !linkedSessionId || !title || !body || !createdAt || !updatedAt) {
return null;
}
return removeUndefined({
id,
projectId: parseOptionalString(value["projectId"]) ?? projectId,
runId,
linkedSessionId,
status: parseFindingStatus(value["status"]),
severity: parseSeverity(value["severity"]),
title,
body,
filePath: parseOptionalString(value["filePath"]),
startLine: parseNumber(value["startLine"]),
endLine: parseNumber(value["endLine"]),
category: parseOptionalString(value["category"]),
confidence: parseNumber(value["confidence"]),
fingerprint: parseOptionalString(value["fingerprint"]),
createdAt,
updatedAt,
dismissedAt: normalizeIsoTimestamp(value["dismissedAt"]),
dismissedBy: parseOptionalString(value["dismissedBy"]),
sentToAgentAt: normalizeIsoTimestamp(value["sentToAgentAt"]),
});
}
export class CodeReviewStore {
readonly projectId: string;
readonly storeDir: string;
constructor(projectId: string, options: CodeReviewStoreOptions = {}) {
this.projectId = projectId;
this.storeDir = options.storeDir ?? getProjectCodeReviewsDir(projectId);
}
get runsDir(): string {
return join(this.storeDir, "runs");
}
get findingsDir(): string {
return join(this.storeDir, "findings");
}
ensure(): void {
mkdirSync(this.runsDir, { recursive: true });
mkdirSync(this.findingsDir, { recursive: true });
}
listRuns(filter: ListCodeReviewRunsFilter = {}): CodeReviewRun[] {
return this.readAllRuns()
.filter((run) => !filter.linkedSessionId || run.linkedSessionId === filter.linkedSessionId)
.filter((run) => !filter.status || run.status === filter.status)
.sort(compareUpdatedDesc);
}
listRunSummaries(filter: ListCodeReviewRunsFilter = {}): CodeReviewRunSummary[] {
const findings = this.listFindings();
const countsByRun = new Map<string, Omit<CodeReviewRunSummary, keyof CodeReviewRun>>();
for (const finding of findings) {
const counts = countsByRun.get(finding.runId) ?? {
findingCount: 0,
openFindingCount: 0,
dismissedFindingCount: 0,
sentFindingCount: 0,
resolvedFindingCount: 0,
};
counts.findingCount++;
if (finding.status === "open") counts.openFindingCount++;
if (finding.status === "dismissed") counts.dismissedFindingCount++;
if (finding.status === "sent_to_agent") counts.sentFindingCount++;
if (finding.status === "resolved") counts.resolvedFindingCount++;
countsByRun.set(finding.runId, counts);
}
return this.listRuns(filter).map((run) => ({
...run,
...(countsByRun.get(run.id) ?? {
findingCount: 0,
openFindingCount: 0,
dismissedFindingCount: 0,
sentFindingCount: 0,
resolvedFindingCount: 0,
}),
}));
}
getRun(runId: string): CodeReviewRun | null {
assertSafeReviewId(runId, "review run id");
return parseRun(this.projectId, readJsonFile(this.runPath(runId)));
}
createRun(input: CreateCodeReviewRunInput, now = new Date()): CodeReviewRun {
const id = `review-run-${randomUUID()}`;
const timestamp = now.toISOString();
const run: CodeReviewRun = removeUndefined({
id,
projectId: this.projectId,
linkedSessionId: input.linkedSessionId,
reviewerSessionId: input.reviewerSessionId,
status: input.status ?? "queued",
createdAt: timestamp,
updatedAt: timestamp,
startedAt: input.status === "running" ? timestamp : undefined,
targetSha: input.targetSha,
baseSha: input.baseSha,
prNumber: input.prNumber,
prUrl: input.prUrl,
reviewerWorkspacePath: input.reviewerWorkspacePath,
summary: input.summary,
});
this.writeRun(run);
return run;
}
updateRun(
runId: string,
patch: Partial<Omit<CodeReviewRun, "id" | "projectId" | "createdAt">>,
now = new Date(),
): CodeReviewRun {
const existing = this.getRun(runId);
if (!existing) {
throw new Error(`Code review run not found: ${runId}`);
}
const next = removeUndefined({
...existing,
...patch,
id: existing.id,
projectId: existing.projectId,
createdAt: existing.createdAt,
updatedAt: now.toISOString(),
});
this.writeRun(next);
return next;
}
listFindings(filter: ListCodeReviewFindingsFilter = {}): CodeReviewFinding[] {
return this.readAllFindings()
.filter((finding) => !filter.runId || finding.runId === filter.runId)
.filter(
(finding) => !filter.linkedSessionId || finding.linkedSessionId === filter.linkedSessionId,
)
.filter((finding) => !filter.status || finding.status === filter.status)
.sort(compareUpdatedDesc);
}
getFinding(findingId: string): CodeReviewFinding | null {
assertSafeReviewId(findingId, "review finding id");
return parseFinding(this.projectId, readJsonFile(this.findingPath(findingId)));
}
createFinding(input: CreateCodeReviewFindingInput, now = new Date()): CodeReviewFinding {
if (!this.getRun(input.runId)) {
throw new Error(`Code review run not found: ${input.runId}`);
}
const id = `review-finding-${randomUUID()}`;
const timestamp = now.toISOString();
const finding: CodeReviewFinding = removeUndefined({
id,
projectId: this.projectId,
runId: input.runId,
linkedSessionId: input.linkedSessionId,
status: input.status ?? "open",
severity: input.severity,
title: input.title,
body: input.body,
filePath: input.filePath,
startLine: input.startLine,
endLine: input.endLine,
category: input.category,
confidence: input.confidence,
fingerprint: input.fingerprint,
createdAt: timestamp,
updatedAt: timestamp,
});
this.writeFinding(finding);
return finding;
}
updateFinding(
findingId: string,
patch: Partial<
Omit<CodeReviewFinding, "id" | "projectId" | "runId" | "linkedSessionId" | "createdAt">
>,
now = new Date(),
): CodeReviewFinding {
const existing = this.getFinding(findingId);
if (!existing) {
throw new Error(`Code review finding not found: ${findingId}`);
}
const next = removeUndefined({
...existing,
...patch,
id: existing.id,
projectId: existing.projectId,
runId: existing.runId,
linkedSessionId: existing.linkedSessionId,
createdAt: existing.createdAt,
updatedAt: now.toISOString(),
});
this.writeFinding(next);
return next;
}
deleteAll(): void {
rmSync(this.storeDir, { recursive: true, force: true });
}
private runPath(runId: string): string {
assertSafeReviewId(runId, "review run id");
return join(this.runsDir, `${runId}.json`);
}
private findingPath(findingId: string): string {
assertSafeReviewId(findingId, "review finding id");
return join(this.findingsDir, `${findingId}.json`);
}
private writeRun(run: CodeReviewRun): void {
this.ensure();
writeJsonFile(this.runPath(run.id), run);
}
private writeFinding(finding: CodeReviewFinding): void {
this.ensure();
writeJsonFile(this.findingPath(finding.id), finding);
}
private readAllRuns(): CodeReviewRun[] {
return this.readAllJsonFiles(this.runsDir)
.map((value) => parseRun(this.projectId, value))
.filter((run): run is CodeReviewRun => run !== null);
}
private readAllFindings(): CodeReviewFinding[] {
return this.readAllJsonFiles(this.findingsDir)
.map((value) => parseFinding(this.projectId, value))
.filter((finding): finding is CodeReviewFinding => finding !== null);
}
private readAllJsonFiles(dir: string): unknown[] {
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => readJsonFile(join(dir, entry.name)))
.filter((value) => value !== null);
}
}
export function createCodeReviewStore(
projectId: string,
options: CodeReviewStoreOptions = {},
): CodeReviewStore {
return new CodeReviewStore(projectId, options);
}

Some files were not shown because too many files have changed in this diff Show More