diff --git a/.changeset/activity-events-webhooks-mux.md b/.changeset/activity-events-webhooks-mux.md new file mode 100644 index 000000000..6f4dcc73f --- /dev/null +++ b/.changeset/activity-events-webhooks-mux.md @@ -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. diff --git a/.changeset/claude-activity-detection-split.md b/.changeset/claude-activity-detection-split.md new file mode 100644 index 000000000..043b65f52 --- /dev/null +++ b/.changeset/claude-activity-detection-split.md @@ -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). diff --git a/.changeset/claude-activity-edge-cases.md b/.changeset/claude-activity-edge-cases.md new file mode 100644 index 000000000..b44c5686f --- /dev/null +++ b/.changeset/claude-activity-edge-cases.md @@ -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 (`/.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//` (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. diff --git a/.changeset/cli-activity-events.md b/.changeset/cli-activity-events.md new file mode 100644 index 000000000..7528033c4 --- /dev/null +++ b/.changeset/cli-activity-events.md @@ -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. diff --git a/.changeset/config.json b/.changeset/config.json index 07ccb4343..0f83c3b95 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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", diff --git a/.changeset/fix-done-bar-scroll.md b/.changeset/fix-done-bar-scroll.md new file mode 100644 index 000000000..f049da4a8 --- /dev/null +++ b/.changeset/fix-done-bar-scroll.md @@ -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`. diff --git a/.changeset/issue-1660-recovery-metadata-events.md b/.changeset/issue-1660-recovery-metadata-events.md new file mode 100644 index 000000000..4ad89f8b0 --- /dev/null +++ b/.changeset/issue-1660-recovery-metadata-events.md @@ -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. diff --git a/.changeset/launch-orchestrator-clean.md b/.changeset/launch-orchestrator-clean.md new file mode 100644 index 000000000..a13c2f708 --- /dev/null +++ b/.changeset/launch-orchestrator-clean.md @@ -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. diff --git a/.changeset/linear-transient-retry.md b/.changeset/linear-transient-retry.md new file mode 100644 index 000000000..cb1ab2764 --- /dev/null +++ b/.changeset/linear-transient-retry.md @@ -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. diff --git a/.changeset/notifier-release-links.md b/.changeset/notifier-release-links.md new file mode 100644 index 000000000..24ffdf344 --- /dev/null +++ b/.changeset/notifier-release-links.md @@ -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. diff --git a/.changeset/quiet-sqlite-rebuild.md b/.changeset/quiet-sqlite-rebuild.md new file mode 100644 index 000000000..79dbfd120 --- /dev/null +++ b/.changeset/quiet-sqlite-rebuild.md @@ -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. diff --git a/.changeset/restore-button-pr-merged.md b/.changeset/restore-button-pr-merged.md new file mode 100644 index 000000000..125ad6b7a --- /dev/null +++ b/.changeset/restore-button-pr-merged.md @@ -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. diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 234c715b6..ef89b8915 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -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: diff --git a/.issue-assets/1736-notifier-logging.png b/.issue-assets/1736-notifier-logging.png new file mode 100644 index 000000000..e6c09b5b1 Binary files /dev/null and b/.issue-assets/1736-notifier-logging.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 42c583e49..731ee041f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, diff --git a/docs/CLI.md b/docs/CLI.md index ab4bae5c7..27411c11a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 # 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 ``` diff --git a/docs/observability.md b/docs/observability.md index f9f061592..746ee012a 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -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. diff --git a/hermes-agent b/hermes-agent deleted file mode 160000 index 27eeea055..000000000 --- a/hermes-agent +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1 diff --git a/packages/ao/bin/postinstall.js b/packages/ao/bin/postinstall.js index feb8f21e2..58caae5fc 100644 --- a/packages/ao/bin/postinstall.js +++ b/packages/ao/bin/postinstall.js @@ -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(); } diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index 5e1663767..5c2f7ce8e 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -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 }, 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); diff --git a/packages/cli/__tests__/commands/events.test.ts b/packages/cli/__tests__/commands/events.test.ts new file mode 100644 index 000000000..e96633234 --- /dev/null +++ b/packages/cli/__tests__/commands/events.test.ts @@ -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; + + 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", + }), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/migrate-storage.test.ts b/packages/cli/__tests__/commands/migrate-storage.test.ts new file mode 100644 index 000000000..cf071ea30 --- /dev/null +++ b/packages/cli/__tests__/commands/migrate-storage.test.ts @@ -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(); + 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> => + vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +describe("ao migrate-storage — activity events", () => { + let program: Command; + let exitSpy: ReturnType; + let consoleErrSpy: ReturnType; + let consoleLogSpy: ReturnType; + + 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", + }), + }), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/notify.test.ts b/packages/cli/__tests__/commands/notify.test.ts new file mode 100644 index 000000000..2f0d94f61 --- /dev/null +++ b/packages/cli/__tests__/commands/notify.test.ts @@ -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 | 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 | 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 }, + reference: string, + ) => ({ + reference, + pluginName: config.notifiers?.[reference]?.plugin ?? reference, + }), + buildCIFailureNotificationData: (input: { + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + failedChecks: Array>; + }) => ({ + ...baseData({ ...input, semanticType: "ci.failing" }), + ci: { status: "failing", failedChecks: input.failedChecks }, + }), + buildPRStateNotificationData: (input: { + eventType: string; + sessionId: string; + projectId: string; + context?: { pr?: Record | 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 | 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 | 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; + let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; + + 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 }) => { + 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(); + }); +}); diff --git a/packages/cli/__tests__/commands/project.test.ts b/packages/cli/__tests__/commands/project.test.ts index 264b64f03..1a322a070 100644 --- a/packages/cli/__tests__/commands/project.test.ts +++ b/packages/cli/__tests__/commands/project.test.ts @@ -23,6 +23,7 @@ vi.mock("@aoagents/ao-core", () => ({ isPortfolioEnabled: () => true, getPortfolio: mockGetPortfolio, getPortfolioSessionCounts: mockGetPortfolioSessionCounts, + recordActivityEvent: vi.fn(), registerProject: mockRegisterProject, unregisterProject: mockUnregisterProject, loadPreferences: mockLoadPreferences, diff --git a/packages/cli/__tests__/commands/review.test.ts b/packages/cli/__tests__/commands/review.test.ts new file mode 100644 index 000000000..19d0b7950 --- /dev/null +++ b/packages/cli/__tests__/commands/review.test.ts @@ -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(); + 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 => mockSessionManager as SessionManager, +})); + +function makeSession(overrides: Partial = {}): 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; + +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"), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index 7914a00ac..3c8155e33 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -12,11 +12,24 @@ const { mockFindConfigFile } = vi.hoisted(() => ({ mockFindConfigFile: vi.fn(), })); -const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({ +const { + mockReadFileSync, + mockWriteFileSync, + mockExistsSync, + mockMkdirSync, + mockCpSync, + mockRmSync, +} = vi.hoisted(() => ({ mockReadFileSync: vi.fn(), mockWriteFileSync: vi.fn(), mockExistsSync: vi.fn(), mockMkdirSync: vi.fn(), + mockCpSync: vi.fn(), + mockRmSync: vi.fn(), +})); + +const { mockExecFileSync } = vi.hoisted(() => ({ + mockExecFileSync: vi.fn(), })); const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = vi.hoisted(() => ({ @@ -25,12 +38,82 @@ const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = mockDetectOpenClawInstallation: vi.fn(), })); +const { + mockComposioConstructorOptions, + mockAuthConfigsList, + mockAuthConfigsCreate, + mockAuthConfigsRetrieve, + mockConnectedAccountsList, + mockConnectedAccountsGet, + mockConnectedAccountsLink, + mockConnectedAccountsInitiate, + mockConnectedAccountsWaitForConnection, + mockToolkitsAuthorize, +} = vi.hoisted(() => ({ + mockComposioConstructorOptions: [] as Array>, + mockAuthConfigsList: vi.fn(), + mockAuthConfigsCreate: vi.fn(), + mockAuthConfigsRetrieve: vi.fn(), + mockConnectedAccountsList: vi.fn(), + mockConnectedAccountsGet: vi.fn(), + mockConnectedAccountsLink: vi.fn(), + mockConnectedAccountsInitiate: vi.fn(), + mockConnectedAccountsWaitForConnection: vi.fn(), + mockToolkitsAuthorize: vi.fn(), +})); + +const { mockFetch } = vi.hoisted(() => ({ + mockFetch: vi.fn(), +})); + +const { mockClack } = vi.hoisted(() => ({ + mockClack: { + cancel: vi.fn(), + confirm: vi.fn(), + intro: vi.fn(), + isCancel: vi.fn(), + log: Object.assign(vi.fn(), { success: vi.fn(), warn: vi.fn() }), + outro: vi.fn(), + password: vi.fn(), + select: vi.fn(), + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + text: vi.fn(), + }, +})); + +function testHttpsUrl(hostParts: string[], path: string): string { + return `https://${hostParts.join(".")}${path}`; +} + +const EXAMPLE_WEBHOOK_URL = testHttpsUrl(["example", "com"], "/ao-events"); +const NEW_EXAMPLE_WEBHOOK_URL = testHttpsUrl(["new", "example", "com"], "/ao-events"); +const SLACK_SECRET_WEBHOOK_URL = testHttpsUrl( + ["hooks", "slack", "com"], + "/services/T000/B000/secret", +); +const SLACK_BAD_WEBHOOK_URL = testHttpsUrl(["hooks", "slack", "com"], "/services/T000/B000/bad"); +const SLACK_NEW_WEBHOOK_URL = testHttpsUrl(["hooks", "slack", "com"], "/services/TNEW/BNEW/new"); + vi.mock("@aoagents/ao-core", () => ({ CONFIG_SCHEMA_URL: "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT: 50, findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + getDashboardNotificationStorePath: (configPath: string) => + `${configPath}.dashboard-notifications.jsonl`, isCanonicalGlobalConfigPath: (configPath: string | undefined) => configPath === join(homedir(), ".agent-orchestrator", "config.yaml"), + normalizeDashboardNotificationLimit: (value: unknown) => { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number.parseInt(value, 10) + : 50; + return Number.isFinite(parsed) ? Math.min(500, Math.max(1, Math.floor(parsed))) : 50; + }, + readDashboardNotificationsFromFile: () => [], + recordActivityEvent: vi.fn(), })); vi.mock("node:fs", async (importOriginal) => { @@ -41,6 +124,16 @@ vi.mock("node:fs", async (importOriginal) => { writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), existsSync: (...args: unknown[]) => mockExistsSync(...args), mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), + cpSync: (...args: unknown[]) => mockCpSync(...args), + rmSync: (...args: unknown[]) => mockRmSync(...args), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), }; }); @@ -52,7 +145,36 @@ vi.mock("../../src/lib/openclaw-probe.js", () => ({ HOOKS_PATH: "/hooks/agent", })); +vi.mock("@composio/core", () => { + function MockComposio(opts: Record) { + mockComposioConstructorOptions.push(opts); + return { + authConfigs: { + list: mockAuthConfigsList, + create: mockAuthConfigsCreate, + get: mockAuthConfigsRetrieve, + retrieve: mockAuthConfigsRetrieve, + }, + connectedAccounts: { + list: mockConnectedAccountsList, + get: mockConnectedAccountsGet, + link: mockConnectedAccountsLink, + initiate: mockConnectedAccountsInitiate, + waitForConnection: mockConnectedAccountsWaitForConnection, + }, + toolkits: { + authorize: mockToolkitsAuthorize, + }, + }; + } + return { Composio: MockComposio }; +}); + +vi.mock("@clack/prompts", () => mockClack); + +import { recordActivityEvent } from "@aoagents/ao-core"; import { registerSetup } from "../../src/commands/setup.js"; +import { applyNotifierRoutingPreset } from "../../src/lib/notifier-routing.js"; // --------------------------------------------------------------------------- // Helpers @@ -94,11 +216,1829 @@ function createProgram(): Command { // Tests // --------------------------------------------------------------------------- +describe("notifier routing helpers", () => { + it("keeps explicit empty priority routes empty unless the preset includes that priority", () => { + const rawConfig: Record = { + defaults: { notifiers: ["slack"] }, + notificationRouting: { + urgent: [], + action: ["slack"], + warning: [], + }, + }; + + applyNotifierRoutingPreset(rawConfig, "desktop", "urgent-action"); + + expect(rawConfig["notificationRouting"]).toEqual({ + urgent: ["desktop"], + action: ["slack", "desktop"], + warning: [], + info: ["slack"], + }); + }); + + it("uses defaults only when a priority route is missing", () => { + const rawConfig: Record = { + defaults: { notifiers: ["slack"] }, + notificationRouting: { + urgent: ["pager"], + }, + }; + + applyNotifierRoutingPreset(rawConfig, "desktop", "urgent-only"); + + expect(rawConfig["notificationRouting"]).toEqual({ + urgent: ["pager", "desktop"], + action: ["slack"], + warning: ["slack"], + info: ["slack"], + }); + }); +}); + +describe("setup dashboard command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes dashboard notifier config with the urgent-action routing default", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "dashboard", + "--non-interactive", + "--limit", + "75", + ]); + + const written = String(mockWriteFileSync.mock.calls[0][1]); + const parsed = parseYaml(written) as { + notifiers?: Record; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["dashboard"]).toEqual({ plugin: "dashboard", limit: 75 }); + expect(parsed.notificationRouting?.urgent).toContain("dashboard"); + expect(parsed.notificationRouting?.action).toContain("dashboard"); + expect(parsed.notificationRouting?.warning ?? []).not.toContain("dashboard"); + expect(parsed.notificationRouting?.info ?? []).not.toContain("dashboard"); + }); + + it("prints status without mutating config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "dashboard", "--status"]); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup composio command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockComposioConstructorOptions.length = 0; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockAuthConfigsList.mockResolvedValue({ + items: [{ id: "auth_slack_123", toolkit: { slug: "slack" } }], + }); + mockAuthConfigsCreate.mockResolvedValue({ + id: "auth_slack_created", + toolkit: { slug: "slack" }, + }); + mockAuthConfigsRetrieve.mockResolvedValue({ + id: "auth_slack_123", + toolkit: { slug: "slack" }, + toolAccessConfig: {}, + }); + mockConnectedAccountsList.mockResolvedValue({ + items: [ + { + id: "ca_slack_123", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockImplementation((id: string) => { + const toolkit = id.includes("discord") + ? "discordbot" + : id.includes("gmail") + ? "gmail" + : "slack"; + return Promise.resolve({ + id, + status: "ACTIVE", + toolkit: { slug: toolkit }, + isDisabled: false, + }); + }); + mockConnectedAccountsWaitForConnection.mockResolvedValue({ + id: "ca_waited", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }); + mockConnectedAccountsLink.mockResolvedValue({ + id: "conn_req_123", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_authorized", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }), + }); + mockConnectedAccountsInitiate.mockResolvedValue({ + id: "ca_discord_123", + status: "ACTIVE", + }); + mockToolkitsAuthorize.mockResolvedValue({ + id: "conn_req_123", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_authorized", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }), + }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: vi.fn().mockResolvedValue({ id: "1234567890", name: "general" }), + }); + vi.stubGlobal("fetch", mockFetch); + for (const fn of Object.values(mockClack)) fn.mockReset(); + mockClack.confirm.mockResolvedValue(true); + mockClack.isCancel.mockReturnValue(false); + mockClack.password.mockResolvedValue("ak_interactive"); + mockClack.select.mockResolvedValue("slack"); + mockClack.text.mockResolvedValue(""); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the composio setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "composio")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-slack")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-discord")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-discord-bot")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-mail")).toBe(true); + }); + + it("runs the interactive Composio hub and writes Slack config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("slack") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_slack_123") + .mockResolvedValueOnce("change") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("iamasx"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockClack.intro).toHaveBeenCalledWith("AO Composio notifier setup"); + expect(mockClack.select).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: "Which Composio app do you want to configure?", + options: expect.arrayContaining([ + expect.objectContaining({ value: "slack" }), + expect.objectContaining({ value: "discord-webhook" }), + expect.objectContaining({ value: "discord-bot" }), + expect.objectContaining({ value: "gmail" }), + ]), + }), + ); + expect(mockComposioConstructorOptions).toEqual([{ apiKey: "ak_interactive" }]); + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["aoagent"], + toolkitSlugs: ["slack"], + statuses: ["ACTIVE"], + limit: 25, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "slack", + composioApiKey: "ak_interactive", + userId: "aoagent", + channelName: "iamasx", + connectedAccountId: "ca_slack_123", + }); + expect(parsed.defaults?.notifiers).toContain("composio"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio"); + }); + + it("interactive Slack setup can generate a Composio connect link", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockClack.select + .mockResolvedValueOnce("slack") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("create-link") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockAuthConfigsList).toHaveBeenCalledWith({ toolkit: "slack" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_slack_123", { + allowMultiple: true, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_authorized"); + }); + + it("interactive Slack setup shows navigation after an unfinished connect link", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_123", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + mockClack.select + .mockResolvedValueOnce("slack") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("create-link") + .mockResolvedValueOnce("check-active") + .mockResolvedValueOnce("ca_slack_123") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After opening the Composio Slack connect link, what do you want to do?", + options: expect.arrayContaining([ + expect.objectContaining({ value: "check-active" }), + expect.objectContaining({ value: "retry-link" }), + expect.objectContaining({ value: "enter-id" }), + expect.objectContaining({ value: "back" }), + expect.objectContaining({ value: "cancel" }), + ]), + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_slack_123"); + }); + + it("runs the interactive Composio hub and writes Discord webhook config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce( + "https://discord.com/api/webhooks/1234567890/webhook-token", + ); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Webhook Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "webhook-token" }, + }); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + expect(mockConnectedAccountsInitiate).toHaveBeenCalled(); + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to configure the Composio Discord webhook connected account?", + }), + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + userId: "aoagent", + toolVersion: "20260429_01", + composioApiKey: "ak_interactive", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.notifiers?.["composio-discord"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio"); + }); + + it("interactive Discord webhook setup can reuse existing config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: webhook + composioApiKey: ak_existing + userId: ao-existing + webhookUrl: https://discord.com/api/webhooks/old/webhook-token + connectedAccountId: ca_discord_existing +projects: + my-app: + name: my-app +`); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_discord_existing"); + expect(mockConnectedAccountsInitiate).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + webhookUrl: "https://discord.com/api/webhooks/old/webhook-token", + userId: "ao-existing", + connectedAccountId: "ca_discord_existing", + }); + }); + + it("interactive Discord webhook setup can show creation steps before URL entry", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("show-steps") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("https://discord.com/api/webhooks/222/webhook-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After creating the Discord webhook, what do you want to do?", + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("webhookUrl: https://discord.com/api/webhooks/222/webhook-token"); + expect(writtenYaml).toContain("connectedAccountId: ca_discord_123"); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + }); + + it("interactive Discord webhook setup replaces the canonical Composio notifier and clears stale app fields", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: slack + composioApiKey: ak_existing + userId: ao-existing + channelName: agents + connectedAccountId: ca_slack_old + emailTo: old@example.com +projects: + my-app: + name: my-app +`); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("https://discord.com/api/webhooks/333/webhook-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + defaults?: { notifiers?: string[] }; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/333/webhook-token", + userId: "ao-existing", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.notifiers?.["composio"]?.channelName).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.emailTo).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("interactive Discord bot setup creates a connected account and writes the canonical Composio notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + process.env.DISCORD_BOT_TOKEN = ""; + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: webhook + composioApiKey: ak_existing + userId: ao-existing + webhookUrl: https://discord.com/api/webhooks/old/webhook-token + channelName: stale-channel + emailTo: old@example.com +projects: + my-app: + name: my-app +`); + mockAuthConfigsCreate.mockResolvedValueOnce({ + id: "auth_discord_created", + toolkit: { slug: "discordbot" }, + }); + mockClack.select + .mockResolvedValueOnce("discord-bot") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-id") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("1234567890"); + mockClack.password.mockResolvedValueOnce("bot-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockFetch).toHaveBeenCalledWith("https://discord.com/api/v10/channels/1234567890", { + headers: { + Authorization: "Bot bot-token", + }, + }); + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Bot Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "bot-token" }, + }); + expect(mockConnectedAccountsInitiate).toHaveBeenCalledWith( + "ao-existing", + "auth_discord_created", + { + allowMultiple: true, + config: { + authScheme: "BEARER_TOKEN", + val: { + status: "ACTIVE", + token: "bot-token", + }, + }, + }, + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + defaults?: { notifiers?: string[] }; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + userId: "ao-existing", + connectedAccountId: "ca_discord_123", + toolVersion: "20260429_01", + }); + expect(parsed.notifiers?.["composio"]?.webhookUrl).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.channelName).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.emailTo).toBeUndefined(); + expect(writtenYaml).not.toContain("bot-token"); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("interactive Discord bot setup reuses an existing connected account", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: bot + composioApiKey: ak_existing + userId: ao-existing + channelId: "1234567890" + connectedAccountId: ca_discord_existing +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValueOnce({ + id: "ca_discord_existing", + status: "ACTIVE", + toolkit: { slug: "discordbot" }, + isDisabled: false, + }); + mockClack.select + .mockResolvedValueOnce("discord-bot") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("write"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_discord_existing"); + expect(mockAuthConfigsCreate).not.toHaveBeenCalled(); + expect(mockConnectedAccountsInitiate).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + connectedAccountId: "ca_discord_existing", + userId: "ao-existing", + }); + }); + + it("interactive Gmail setup chooses an active account and writes the canonical Composio notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: webhook + composioApiKey: ak_existing + userId: ao-existing + webhookUrl: https://discord.com/api/webhooks/old/webhook-token + channelName: stale-channel +projects: + my-app: + name: my-app +`); + mockConnectedAccountsList.mockResolvedValueOnce({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_gmail_123") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["ao-existing"], + toolkitSlugs: ["gmail"], + statuses: ["ACTIVE"], + limit: 25, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + userId: "ao-existing", + connectedAccountId: "ca_gmail_123", + toolVersion: "20260506_01", + }); + expect(parsed.notifiers?.["composio"]?.mode).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.webhookUrl).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.channelName).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("interactive Gmail setup reuses an existing connected account", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: gmail + composioApiKey: ak_existing + userId: ao-existing + emailTo: admin@example.com + connectedAccountId: ca_gmail_existing +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_existing", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("write"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_gmail_existing"); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + defaultApp: "gmail", + emailTo: "admin@example.com", + connectedAccountId: "ca_gmail_existing", + userId: "ao-existing", + }); + }); + + it("interactive Gmail setup can generate a connect link from an existing auth config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockAuthConfigsList.mockResolvedValueOnce({ + items: [ + { + id: "auth_gmail_send", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }, + ], + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_gmail", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }), + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("create-link") + .mockResolvedValueOnce("choose-existing") + .mockResolvedValueOnce("auth_gmail_send") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockAuthConfigsCreate).not.toHaveBeenCalledWith("gmail", expect.anything()); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_gmail_send", { + allowMultiple: true, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_gmail_authorized"); + expect(writtenYaml).toContain("emailTo: admin@example.com"); + }); + + it("interactive Gmail setup rejects accounts without Gmail send access", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: gmail + composioApiKey: ak_existing + userId: ao-existing + emailTo: admin@example.com + connectedAccountId: ca_gmail_bad +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_bad", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: "openid https://www.googleapis.com/auth/userinfo.email", + }, + }); + mockAuthConfigsRetrieve.mockResolvedValueOnce(null); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "composio"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("interactive hub can be cancelled from app choices", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "composio"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("interactive Composio Slack setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_slack_123") + .mockResolvedValueOnce("change") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("iamasx"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-slack"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-slack"]).toMatchObject({ + plugin: "composio", + defaultApp: "slack", + composioApiKey: "ak_interactive", + userId: "aoagent", + channelName: "iamasx", + connectedAccountId: "ca_slack_123", + }); + expect(parsed.notifiers?.["composio"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio-slack"); + }); + + it("preserves an existing custom Composio userId", async () => { + mockReadFileSync.mockReturnValue(` +notifiers: + composio-slack: + plugin: composio + defaultApp: slack + userId: existing-user +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-slack", + "--api-key", + "ak_test", + "--connected-account-id", + "ca_slack_123", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-slack"]?.["userId"]).toBe("existing-user"); + }); + + it("interactive Composio Discord webhook setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce( + "https://discord.com/api/webhooks/1234567890/webhook-token", + ); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-discord"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-discord"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + userId: "aoagent", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.notifiers?.["composio"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio-discord"); + }); + + it("interactive Composio Discord bot setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + process.env.DISCORD_BOT_TOKEN = ""; + mockAuthConfigsCreate.mockResolvedValueOnce({ + id: "auth_discord_created", + toolkit: { slug: "discordbot" }, + }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-id") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("1234567890"); + mockClack.password.mockResolvedValueOnce("ak_interactive").mockResolvedValueOnce("bot-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-discord-bot"]); + + expect(mockFetch).toHaveBeenCalledWith("https://discord.com/api/v10/channels/1234567890", { + headers: { + Authorization: "Bot bot-token", + }, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-discord-bot"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + userId: "aoagent", + connectedAccountId: "ca_discord_123", + }); + expect(writtenYaml).not.toContain("bot-token"); + expect(parsed.defaults?.notifiers).toContain("composio-discord-bot"); + }); + + it("interactive Composio mail setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsList.mockResolvedValueOnce({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_gmail_123") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-mail"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-mail"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + userId: "aoagent", + connectedAccountId: "ca_gmail_123", + }); + expect(parsed.notifiers?.["composio"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio-mail"); + }); + + it("interactive Composio direct app flag writes the canonical notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsList.mockResolvedValueOnce({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_gmail_123") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio", "--gmail"]); + + expect(mockClack.select).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: "Which Composio app do you want to configure?", + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + connectedAccountId: "ca_gmail_123", + }); + expect(parsed.notifiers?.["composio-mail"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("writes Composio config with a discovered Slack connected account", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--channel", + "iamasx", + "--non-interactive", + ]); + + expect(mockComposioConstructorOptions).toEqual([{ apiKey: "ak_test" }]); + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["ao-user"], + toolkitSlugs: ["slack"], + statuses: ["ACTIVE"], + limit: 25, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "slack", + composioApiKey: "ak_test", + userId: "ao-user", + channelName: "iamasx", + connectedAccountId: "ca_slack_123", + }); + expect(parsed.defaults?.notifiers).toContain("composio"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio"); + expect(parsed.notificationRouting?.["action"]).toContain("composio"); + expect(parsed.notificationRouting?.["warning"]).toContain("composio"); + expect(parsed.notificationRouting?.["info"]).toContain("composio"); + }); + + it("uses COMPOSIO_API_KEY and does not write the env value to config", async () => { + process.env.COMPOSIO_API_KEY = "ak_env"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--user-id", + "ao-user", + "--non-interactive", + ]); + + expect(mockComposioConstructorOptions).toEqual([{ apiKey: "ak_env" }]); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).not.toContain("ak_env"); + }); + + it("verifies and stores an explicit connected account id", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--connected-account-id", + "ca_explicit", + "--non-interactive", + ]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_explicit"); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]?.["connectedAccountId"]).toBe("ca_explicit"); + }); + + it("fails in non-interactive mode when multiple Slack accounts need selection", async () => { + mockConnectedAccountsList.mockResolvedValue({ + items: [ + { id: "ca_one", status: "ACTIVE", toolkit: { slug: "slack" } }, + { id: "ca_two", status: "ACTIVE", toolkit: { slug: "slack" } }, + ], + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("creates a Slack connect request when no active account exists", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsList).toHaveBeenCalledWith({ toolkit: "slack" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("ao-user", "auth_slack_123", { + allowMultiple: true, + }); + expect(mockToolkitsAuthorize).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_authorized"); + }); + + it("does not write Slack Composio config when a connect request does not complete", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_slack", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("ao-user", "auth_slack_123", { + allowMultiple: true, + }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("creates a Slack auth config before linking when none exists", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsList.mockResolvedValue({ items: [] }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("slack", { + type: "use_composio_managed_auth", + name: "Slack Auth Config", + }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("ao-user", "auth_slack_created", { + allowMultiple: true, + }); + }); + + it("shows status without writing config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--status", + ]); + + expect(mockConnectedAccountsList).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting composio notifier config unless --force is set", async () => { + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes Composio Discord webhook config with a connected account", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-discord", + "--api-key", + "ak_test", + "--webhook-url", + "https://discord.com/api/webhooks/1234567890/webhook-token", + "--non-interactive", + ]); + + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Webhook Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "webhook-token" }, + }); + expect(mockConnectedAccountsInitiate).toHaveBeenCalled(); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio-discord"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + userId: "aoagent", + toolVersion: "20260429_01", + composioApiKey: "ak_test", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.defaults?.notifiers).toContain("composio-discord"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio-discord"); + expect(writtenYaml).not.toContain("botToken"); + }); + + it("writes Composio Discord bot config and does not persist the bot token", async () => { + mockAuthConfigsCreate.mockResolvedValueOnce({ + id: "auth_discord_created", + toolkit: { slug: "discordbot" }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-discord-bot", + "--api-key", + "ak_test", + "--channel-id", + "1234567890", + "--bot-token", + "bot-token", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith("https://discord.com/api/v10/channels/1234567890", { + headers: { + Authorization: "Bot bot-token", + }, + }); + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Bot Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "bot-token" }, + }); + expect(mockConnectedAccountsInitiate).toHaveBeenCalledWith("aoagent", "auth_discord_created", { + allowMultiple: true, + config: { + authScheme: "BEARER_TOKEN", + val: { + status: "ACTIVE", + token: "bot-token", + }, + }, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio-discord-bot"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + userId: "aoagent", + connectedAccountId: "ca_discord_123", + toolVersion: "20260429_01", + composioApiKey: "ak_test", + }); + expect(parsed.defaults?.notifiers).toContain("composio-discord-bot"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio-discord-bot"); + expect(writtenYaml).not.toContain("bot-token"); + }); + + it("fails Discord bot setup when the bot cannot access the channel", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + json: vi.fn().mockResolvedValue({ message: "Missing Access" }), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio-discord-bot", + "--api-key", + "ak_test", + "--channel-id", + "1234567890", + "--bot-token", + "bot-token", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes Discord bot config from an explicit connected account without a bot token", async () => { + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_discord_explicit", + status: "ACTIVE", + toolkit: { slug: "discordbot" }, + isDisabled: false, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-discord-bot", + "--api-key", + "ak_test", + "--channel-id", + "1234567890", + "--connected-account-id", + "ca_discord_explicit", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockConnectedAccountsInitiate).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-discord-bot"]?.["connectedAccountId"]).toBe( + "ca_discord_explicit", + ); + }); + + it("writes Composio mail config with a discovered Gmail connected account", async () => { + mockConnectedAccountsList.mockResolvedValue({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--email-to", + "admin@example.com", + "--non-interactive", + ]); + + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["ao-user"], + toolkitSlugs: ["gmail"], + statuses: ["ACTIVE"], + limit: 25, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio-mail"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + userId: "ao-user", + connectedAccountId: "ca_gmail_123", + toolVersion: "20260506_01", + composioApiKey: "ak_test", + }); + expect(parsed.defaults?.notifiers).toContain("composio-mail"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio-mail"); + }); + + it("fails when no usable Gmail connected account exists", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockAuthConfigsCreate).not.toHaveBeenCalledWith("gmail", expect.anything()); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("prints a Gmail connect URL without writing config when --connect does not complete", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsList.mockResolvedValueOnce({ + items: [ + { + id: "auth_gmail_send", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }, + ], + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_123", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connect", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsList).toHaveBeenCalledWith({ toolkit: "gmail" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_gmail_send", { + allowMultiple: true, + }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes mail config when --connect completes with a Gmail connected account", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsList.mockResolvedValueOnce({ + items: [ + { + id: "auth_gmail_send", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }, + ], + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_123", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }), + }); + mockConnectedAccountsGet.mockResolvedValueOnce({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connect", + "--wait-ms", + "1", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_gmail_authorized"); + }); + + it("uses an explicit Gmail auth config id for --connect", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsRetrieve.mockResolvedValueOnce({ + id: "auth_gmail_custom", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_custom", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connect", + "--auth-config-id", + "auth_gmail_custom", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsList).not.toHaveBeenCalledWith({ toolkit: "gmail" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_gmail_custom", { + allowMultiple: true, + }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes mail config from an explicit Gmail connected account", async () => { + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_explicit", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connected-account-id", + "ca_gmail_explicit", + "--non-interactive", + ]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_gmail_explicit"); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-mail"]?.["connectedAccountId"]).toBe("ca_gmail_explicit"); + }); + + it("fails when the existing Gmail account lacks send access and no replacement exists", async () => { + mockReadFileSync.mockReturnValue(` +notifiers: + composio-mail: + plugin: composio + defaultApp: gmail + composioApiKey: ak_existing + emailTo: admin@example.com + connectedAccountId: ca_gmail_old +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_old", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: "openid https://www.googleapis.com/auth/userinfo.email", + }, + }); + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect( + program.parseAsync(["node", "test", "setup", "composio-mail", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockAuthConfigsCreate).not.toHaveBeenCalledWith("gmail", expect.anything()); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + describe("setup openclaw command", () => { const originalEnv = { ...process.env }; beforeEach(() => { vi.restoreAllMocks(); + vi.mocked(recordActivityEvent).mockClear(); mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); mockWriteFileSync.mockImplementation(() => {}); @@ -131,12 +2071,15 @@ describe("setup openclaw command", () => { "--non-interactive", ]); - // Code writes YAML config + shell profile export — at least one write expect(mockWriteFileSync).toHaveBeenCalled(); const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; expect(writtenYaml).toContain("openclaw"); expect(writtenYaml).toContain("plugin: openclaw"); expect(writtenYaml).toContain("http://127.0.0.1:18789/hooks/agent"); + expect(writtenYaml).toContain("token: test-token"); + expect(mockWriteFileSync.mock.calls.some(([path]) => String(path).includes(".zshrc"))).toBe( + false, + ); }); it("reads token from OPENCLAW_HOOKS_TOKEN env var and skips validation", async () => { @@ -156,6 +2099,36 @@ describe("setup openclaw command", () => { // Non-interactive mode skips pre-write validation expect(mockValidateToken).not.toHaveBeenCalled(); expect(mockWriteFileSync).toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("${OPENCLAW_HOOKS_TOKEN}"); + }); + + it("reads token from OpenClaw config without copying it into AO config", async () => { + const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); + mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath); + mockReadFileSync.mockImplementation((path: string) => { + if (path === "/tmp/agent-orchestrator.yaml") return MINIMAL_CONFIG; + if (path === openclawConfigPath) { + return JSON.stringify({ hooks: { token: "openclaw-owned-token" } }); + } + return ""; + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("openclawConfigPath: ~/.openclaw/openclaw.json"); + expect(writtenYaml).not.toContain("openclaw-owned-token"); + expect(mockWriteFileSync.mock.calls).toHaveLength(1); }); it("reads URL from OPENCLAW_GATEWAY_URL env var and skips validation", async () => { @@ -196,6 +2169,25 @@ describe("setup openclaw command", () => { expect(writtenYaml).not.toContain("/hooks/agent/hooks/agent"); }); + it("refreshes existing config without requiring --url", async () => { + process.env["OPENCLAW_HOOKS_TOKEN"] = "env-token"; + mockReadFileSync.mockReturnValue(CONFIG_WITH_OPENCLAW); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--refresh", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("url: http://127.0.0.1:18789/hooks/agent"); + expect(mockDetectOpenClawInstallation).not.toHaveBeenCalled(); + }); + it("skips token validation and writes config in non-interactive mode", async () => { const program = createProgram(); @@ -218,6 +2210,32 @@ describe("setup openclaw command", () => { }); }); + describe("status", () => { + it("shows status without writing config", async () => { + process.env["OPENCLAW_HOOKS_TOKEN"] = "env-token"; + mockReadFileSync.mockReturnValue(CONFIG_WITH_OPENCLAW); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "running", + gatewayUrl: "http://127.0.0.1:18789", + binaryPath: "/usr/local/bin/openclaw", + configPath: join(homedir(), ".openclaw", "openclaw.json"), + probe: { reachable: true, httpStatus: 200 }, + }); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "openclaw", "--status"]); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockDetectOpenClawInstallation).toHaveBeenCalledWith( + "http://127.0.0.1:18789/hooks/agent", + ); + expect(mockValidateToken).toHaveBeenCalledWith( + "http://127.0.0.1:18789/hooks/agent", + "env-token", + ); + }); + }); + describe("config writing", () => { it("adds openclaw to defaults.notifiers", async () => { const program = createProgram(); @@ -310,6 +2328,56 @@ projects: expect(parsed.defaults?.notifiers?.filter((name) => name === "openclaw")).toHaveLength(1); }); + it("preserves defaults and routing when OpenClaw refresh has no routing preset", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +defaults: + notifiers: + - slack +notifiers: + slack: + plugin: slack + openclaw: + plugin: openclaw + url: http://127.0.0.1:18789/hooks/agent + token: tok +notificationRouting: + urgent: [] + action: + - slack + warning: [] + info: + - slack +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--refresh", + "--no-test", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notificationRouting?: Record; + }; + expect(parsed.defaults?.notifiers).toEqual(["slack"]); + expect(parsed.notificationRouting).toEqual({ + urgent: [], + action: ["slack"], + warning: [], + info: ["slack"], + }); + }); + it("writes correct notifier block structure", async () => { const program = createProgram(); @@ -328,7 +2396,7 @@ projects: const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; expect(writtenYaml).toContain("plugin: openclaw"); expect(writtenYaml).toContain("http://custom:9999/hooks/agent"); - expect(writtenYaml).toContain("${OPENCLAW_HOOKS_TOKEN}"); + expect(writtenYaml).toContain("token: tok"); expect(writtenYaml).toContain("retries: 3"); expect(writtenYaml).toContain("retryDelayMs: 1000"); expect(writtenYaml).toContain("wakeMode: now"); @@ -388,7 +2456,7 @@ projects: expect(parsed.notificationRouting?.["info"]).toContain("openclaw"); }); - it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => { + it("does not rewrite OpenClaw config when using a token from OpenClaw config", async () => { const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath); @@ -419,31 +2487,16 @@ projects: "openclaw", "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", - "new-token", "--non-interactive", ]); const openclawWrite = mockWriteFileSync.mock.calls.find( ([path]) => path === openclawConfigPath, ); - expect(openclawWrite).toBeDefined(); - - const writtenJson = JSON.parse(openclawWrite![1] as string) as { - hooks: { - token: string; - enabled: boolean; - allowRequestSessionKey: boolean; - allowedSessionKeyPrefixes: string[]; - }; - otherConfig: boolean; - }; - - expect(writtenJson.otherConfig).toBe(true); - expect(writtenJson.hooks.token).toBe("new-token"); - expect(writtenJson.hooks.enabled).toBe(true); - expect(writtenJson.hooks.allowRequestSessionKey).toBe(true); - expect(writtenJson.hooks.allowedSessionKeyPrefixes).toEqual(["legacy:", "hook:"]); + expect(openclawWrite).toBeUndefined(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("openclawConfigPath: ~/.openclaw/openclaw.json"); + expect(writtenYaml).not.toContain("old-token"); }); it("preserves existing projects in config", async () => { @@ -560,22 +2613,1756 @@ projects: expect(exitSpy).toHaveBeenCalledWith(1); }); - it("auto-generates token when --token missing in non-interactive mode", async () => { + it("fails when no OpenClaw-owned token is available in non-interactive mode", async () => { delete process.env["OPENCLAW_HOOKS_TOKEN"]; const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); - await program.parseAsync([ - "node", - "test", - "setup", - "openclaw", - "--url", - "http://127.0.0.1:18789/hooks/agent", - "--non-interactive", - ]); + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); - // nonInteractiveSetup auto-generates a token when none is provided - expect(mockWriteFileSync).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting openclaw notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +defaults: {} +notifiers: + openclaw: + plugin: webhook + url: https://example.com/hook +projects: {} +`); + const program = createProgram(); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); }); }); }); + +describe("setup desktop command", () => { + const originalEnv = { ...process.env }; + const sourceApp = "/tmp/source/AO Notifier.app"; + const targetApp = "/tmp/home/Applications/AO Notifier.app"; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + process.env["AO_DESKTOP_SETUP_PLATFORM"] = "darwin"; + process.env["AO_NOTIFIER_MACOS_APP_PATH"] = sourceApp; + process.env["AO_DESKTOP_APP_INSTALL_PATH"] = targetApp; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockMkdirSync.mockImplementation(() => undefined); + mockCpSync.mockImplementation(() => undefined); + mockRmSync.mockImplementation(() => undefined); + mockExistsSync.mockImplementation((path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier"), + ); + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes("--permission-status-json")) { + return '{"status":"authorized","bundleId":"com.aoagents.notifier"}'; + } + if (args.includes("--version-json")) { + return '{"name":"AO Notifier","version":"0.6.0","bundleId":"com.aoagents.notifier"}'; + } + if (args.includes("--request-permission")) { + return '{"status":"authorized","bundleId":"com.aoagents.notifier"}'; + } + return ""; + }); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("registers the desktop setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "desktop")).toBe(true); + }); + + it("installs the bundled app and wires desktop routing to all priorities", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]); + + expect(mockCpSync).toHaveBeenCalledWith(sourceApp, targetApp, { recursive: true }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "ao-app", + dashboardUrl: "http://localhost:3000", + }); + expect(parsed.notificationRouting?.["urgent"]).toContain("desktop"); + expect(parsed.notificationRouting?.["action"]).toContain("desktop"); + expect(parsed.notificationRouting?.["warning"]).toContain("desktop"); + expect(parsed.notificationRouting?.["info"]).toContain("desktop"); + }); + + it("configures terminal-notifier backend without installing AO Notifier.app", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "terminal-notifier", + "--non-interactive", + ]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith("terminal-notifier", ["--version"], { + stdio: "ignore", + windowsHide: true, + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "terminal-notifier", + [ + "-title", + "AO Notifier", + "-message", + "Desktop notifications are ready.", + "-open", + "http://localhost:3000", + ], + expect.any(Object), + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "terminal-notifier", + dashboardUrl: "http://localhost:3000", + }); + }); + + it("configures osascript backend without installing AO Notifier.app", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "osascript", + "--non-interactive", + ]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith("osascript", ["--version"], { + stdio: "ignore", + windowsHide: true, + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "osascript", + ["-e", 'display notification "Desktop notifications are ready." with title "AO Notifier"'], + expect.any(Object), + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "osascript", + }); + }); + + it("refreshes existing backend and dashboard URL without reinstalling when app exists", async () => { + mockReadFileSync.mockReturnValue(` +port: 4217 +notifiers: + desktop: + plugin: desktop + backend: terminal-notifier + dashboardUrl: http://localhost:3000 + sound: false +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--refresh", + "--no-test", + "--non-interactive", + ]); + + expect(mockCpSync).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { plugin?: string; backend?: string; dashboardUrl?: string; sound?: boolean } + >; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "terminal-notifier", + dashboardUrl: "http://localhost:4217", + sound: false, + }); + }); + + it("uses explicit dashboard URL override", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "osascript", + "--dashboard-url", + "http://localhost:7777", + "--no-test", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]?.dashboardUrl).toBe("http://localhost:7777"); + }); + + it("installs and writes an explicit AO app path", async () => { + const customAppPath = "/tmp/custom/AO Notifier.app"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--app-path", + customAppPath, + "--force", + "--non-interactive", + ]); + + expect(mockCpSync).toHaveBeenCalledWith(sourceApp, customAppPath, { recursive: true }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]?.appPath).toBe(customAppPath); + }); + + it("skips setup test notification with --no-test", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--no-test", + "--non-interactive", + ]); + + expect(mockExecFileSync).not.toHaveBeenCalledWith( + expect.stringContaining("ao-notifier"), + ["--notify-base64", expect.any(String)], + expect.any(Object), + ); + }); + + it("fails for missing terminal-notifier in non-interactive mode", async () => { + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "terminal-notifier" && args[0] === "--version") { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return ""; + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "terminal-notifier", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("preserves existing routing entries while adding desktop", async () => { + mockReadFileSync.mockReturnValue(` +port: 3001 +defaults: + notifiers: + - slack +notifiers: + slack: + plugin: slack +notificationRouting: + urgent: + - slack +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notificationRouting?: Record; + defaults?: { notifiers?: string[] }; + }; + expect(parsed.notificationRouting?.["urgent"]).toEqual(["slack", "desktop"]); + expect(parsed.notificationRouting?.["action"]).toEqual(["slack", "desktop"]); + expect(parsed.defaults?.notifiers).toEqual(["slack"]); + }); + + it("fails on conflicting desktop notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + desktop: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("stops immediately when interactive conflict replacement is declined", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + desktop: + plugin: webhook +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.confirm.mockResolvedValueOnce(false); + mockClack.isCancel.mockReturnValue(false); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop"]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("allows replacing conflicting desktop notifier config with --force", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + desktop: + plugin: webhook + url: http://example.com +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--force", "--non-interactive"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ plugin: "desktop", backend: "ao-app" }); + }); + + it("reports denied notification permission without writing config", async () => { + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes("--request-permission")) { + const error = new Error("Command failed") as Error & { stdout: Buffer; stderr: Buffer }; + error.stdout = Buffer.from('{"status":"denied","bundleId":"com.aoagents.notifier"}\n'); + error.stderr = Buffer.alloc(0); + throw error; + } + return ""; + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("System Settings")); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("refuses to install a non-macOS placeholder AO Notifier.app", async () => { + mockExistsSync.mockImplementation( + (path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier") || + path.endsWith("AO Notifier.app/Contents/Resources/ao-notifier-placeholder"), + ); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("non-macOS placeholder")); + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("shows status without installing or writing config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--status"]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith( + expect.stringContaining("ao-notifier"), + ["--version-json"], + expect.any(Object), + ); + }); + + it("uninstalls the app without changing config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--uninstall"]); + + expect(mockRmSync).toHaveBeenCalledWith(targetApp, { recursive: true, force: true }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("exits on non-macOS install attempts", async () => { + process.env["AO_DESKTOP_SETUP_PLATFORM"] = "linux"; + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockCpSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup webhook command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + statusText: "No Content", + text: vi.fn().mockResolvedValue(""), + }); + vi.stubGlobal("fetch", mockFetch); + for (const fn of Object.values(mockClack)) fn.mockReset(); + mockClack.confirm.mockResolvedValue(true); + mockClack.isCancel.mockReturnValue(false); + mockClack.password.mockResolvedValue(""); + mockClack.select.mockResolvedValue("use-existing"); + mockClack.text.mockResolvedValue(""); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the webhook setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "webhook")).toBe(true); + }); + + it("interactive setup asks whether to use an existing webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("use-existing"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Webhook notifier is already configured. What do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith("https://old.example.com/ao-events", expect.any(Object)); + }); + + it("interactive setup can add a new webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("add-new").mockResolvedValueOnce("enter-url"); + mockClack.text.mockResolvedValueOnce(NEW_EXAMPLE_WEBHOOK_URL); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to configure the webhook URL?", + }), + ); + expect(mockFetch).toHaveBeenCalledWith("https://new.example.com/ao-events", expect.any(Object)); + }); + + it("interactive setup can navigate back from adding a new webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("add-new") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to configure the webhook URL?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith("https://old.example.com/ao-events", expect.any(Object)); + }); + + it("interactive setup can be cancelled before writing webhook config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "webhook"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("tests the endpoint and writes url-only webhook config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/ao-events", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { plugin?: string; url?: string; headers?: Record } + >; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["webhook"]).toMatchObject({ + plugin: "webhook", + url: "https://example.com/ao-events", + }); + expect(parsed.notifiers?.["webhook"]?.headers).toBeUndefined(); + expect(parsed.notificationRouting?.["urgent"]).toContain("webhook"); + expect(parsed.notificationRouting?.["action"]).toContain("webhook"); + expect(parsed.notificationRouting?.["warning"]).toContain("webhook"); + expect(parsed.notificationRouting?.["info"]).toContain("webhook"); + }); + + it("writes bearer auth into webhook headers when auth token is provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--auth-token", + "secret-token", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/ao-events", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + Authorization: "Bearer secret-token", + }, + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record }>; + }; + + expect(parsed.notifiers?.["webhook"]?.headers).toEqual({ + Authorization: "Bearer secret-token", + }); + }); + + it("does not write config when setup test fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue("bad token"), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--auth-token", + "bad-token", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes config without probing when --no-test is used", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--no-test", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("refreshes existing webhook config and preserves bearer token", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events + headers: + Authorization: Bearer existing-token + retries: 5 + retryDelayMs: 2500 +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--refresh", + "--url", + NEW_EXAMPLE_WEBHOOK_URL, + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { url?: string; headers?: Record; retries?: number; retryDelayMs?: number } + >; + }; + + expect(parsed.notifiers?.["webhook"]).toMatchObject({ + url: "https://new.example.com/ao-events", + headers: { Authorization: "Bearer existing-token" }, + retries: 5, + retryDelayMs: 2500, + }); + }); + + it("shows status without writing config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://example.com/ao-events +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook", "--status"]); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting webhook notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: slack +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup slack command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: vi.fn().mockResolvedValue("ok"), + }); + vi.stubGlobal("fetch", mockFetch); + for (const fn of Object.values(mockClack)) fn.mockReset(); + mockClack.confirm.mockResolvedValue(true); + mockClack.isCancel.mockReturnValue(false); + mockClack.select.mockResolvedValue("have-url"); + mockClack.text.mockResolvedValue(""); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the slack setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "slack")).toBe(true); + }); + + it("interactive setup asks whether to reuse an existing Slack webhook", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old + channel: "#old-agents" + username: Existing AO +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("use-existing"); + mockClack.text.mockResolvedValueOnce("#agents").mockResolvedValueOnce("AO"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Slack notifier is already configured. What do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/TOLD/BOLD/old", + expect.any(Object), + ); + }); + + it("interactive setup prints creation steps and waits for a Slack webhook URL", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("need-url").mockResolvedValueOnce("enter-url"); + mockClack.text + .mockResolvedValueOnce(SLACK_SECRET_WEBHOOK_URL) + .mockResolvedValueOnce("") + .mockResolvedValueOnce("AO"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Create a Slack incoming webhook")); + expect(mockClack.text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/T000/B000/secret", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from Slack webhook instructions", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("need-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text.mockResolvedValueOnce("").mockResolvedValueOnce("AO"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After creating the Slack webhook, what do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/TOLD/BOLD/old", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from changing the Slack webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("change-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text.mockResolvedValueOnce("").mockResolvedValueOnce("AO"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to change the Slack webhook URL?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/TOLD/BOLD/old", + expect.any(Object), + ); + }); + + it("interactive setup can be cancelled before writing Slack config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "slack"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("tests the endpoint and writes url-only Slack config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/T000/B000/secret", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + channel?: string; + text?: string; + }; + expect(payload).toMatchObject({ + username: "Agent Orchestrator", + text: "AO Slack notifications are ready.", + }); + expect(payload.channel).toBeUndefined(); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { plugin?: string; webhookUrl?: string; username?: string; channel?: string } + >; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["slack"]).toMatchObject({ + plugin: "slack", + webhookUrl: "https://hooks.slack.com/services/T000/B000/secret", + username: "Agent Orchestrator", + }); + expect(parsed.notifiers?.["slack"]?.channel).toBeUndefined(); + expect(parsed.notificationRouting?.["urgent"]).toContain("slack"); + expect(parsed.notificationRouting?.["action"]).toContain("slack"); + expect(parsed.notificationRouting?.["warning"]).toContain("slack"); + expect(parsed.notificationRouting?.["info"]).toContain("slack"); + }); + + it("writes optional channel and username when provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--channel", + "#agents", + "--username", + "AO", + "--non-interactive", + ]); + + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + channel?: string; + }; + expect(payload).toMatchObject({ + username: "AO", + channel: "#agents", + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["slack"]).toMatchObject({ + username: "AO", + channel: "#agents", + }); + }); + + it("does not write config when Slack setup test fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: vi.fn().mockResolvedValue("no_service"), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_BAD_WEBHOOK_URL, + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes config without probing when --no-test is used", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--no-test", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("refreshes existing Slack config and preserves channel and username", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old + channel: "#old-agents" + username: AO +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--refresh", + "--webhook-url", + SLACK_NEW_WEBHOOK_URL, + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + + expect(parsed.notifiers?.["slack"]).toMatchObject({ + webhookUrl: "https://hooks.slack.com/services/TNEW/BNEW/new", + channel: "#old-agents", + username: "AO", + }); + }); + + it("shows status without writing config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/T000/B000/secret +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack", "--status"]); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting Slack notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup discord command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + statusText: "No Content", + text: vi.fn().mockResolvedValue(""), + headers: { get: vi.fn().mockReturnValue(null) }, + }); + vi.stubGlobal("fetch", mockFetch); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the discord setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "discord")).toBe(true); + }); + + it("interactive setup asks whether to reuse an existing Discord webhook", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/existing/secret + username: Existing AO +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("use-existing"); + mockClack.text + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Discord notifier is already configured. What do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/existing/secret", + expect.any(Object), + ); + }); + + it("interactive setup prints creation steps and waits for a Discord webhook URL", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("need-url").mockResolvedValueOnce("enter-url"); + mockClack.text + .mockResolvedValueOnce("https://discord.com/api/webhooks/123/secret") + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Create a Discord incoming webhook"), + ); + expect(mockClack.text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/123/secret", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from Discord webhook instructions", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/existing/secret +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("need-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After creating the Discord webhook, what do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/existing/secret", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from changing the Discord webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/existing/secret +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("change-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to change the Discord webhook URL?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/existing/secret", + expect.any(Object), + ); + }); + + it("interactive setup can be cancelled before writing Discord config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "discord"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("tests the endpoint and writes url-only Discord config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/123/secret", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + content?: string; + avatar_url?: string; + }; + expect(payload).toMatchObject({ + username: "Agent Orchestrator", + content: "AO Discord notifications are ready.", + }); + expect(payload.avatar_url).toBeUndefined(); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { + plugin?: string; + webhookUrl?: string; + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: number; + retryDelayMs?: number; + } + >; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["discord"]).toMatchObject({ + plugin: "discord", + webhookUrl: "https://discord.com/api/webhooks/123/secret", + username: "Agent Orchestrator", + retries: 2, + retryDelayMs: 1000, + }); + expect(parsed.notifiers?.["discord"]?.avatarUrl).toBeUndefined(); + expect(parsed.notifiers?.["discord"]?.threadId).toBeUndefined(); + expect(parsed.notificationRouting?.["urgent"]).toContain("discord"); + expect(parsed.notificationRouting?.["action"]).toContain("discord"); + expect(parsed.notificationRouting?.["warning"]).toContain("discord"); + expect(parsed.notificationRouting?.["info"]).toContain("discord"); + }); + + it("writes optional username avatar thread and retry config when provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--username", + "AO", + "--avatar-url", + "https://example.com/avatar.png", + "--thread-id", + "987654321", + "--retries", + "4", + "--retry-delay-ms", + "2500", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/123/secret?thread_id=987654321", + expect.any(Object), + ); + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + avatar_url?: string; + }; + expect(payload).toMatchObject({ + username: "AO", + avatar_url: "https://example.com/avatar.png", + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: number; + retryDelayMs?: number; + } + >; + }; + expect(parsed.notifiers?.["discord"]).toMatchObject({ + username: "AO", + avatarUrl: "https://example.com/avatar.png", + threadId: "987654321", + retries: 4, + retryDelayMs: 2500, + }); + }); + + it("does not write config when Discord setup test fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: vi.fn().mockResolvedValue("Unknown Webhook"), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/bad", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes config without probing when --no-test is used", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--no-test", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("refreshes existing Discord config and preserves optional values", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/old/old + username: AO + avatarUrl: https://example.com/avatar.png + threadId: "111" + retries: 5 + retryDelayMs: 3000 +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--refresh", + "--webhook-url", + "https://discord.com/api/webhooks/new/new", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { + webhookUrl?: string; + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: number; + retryDelayMs?: number; + } + >; + }; + + expect(parsed.notifiers?.["discord"]).toMatchObject({ + webhookUrl: "https://discord.com/api/webhooks/new/new", + username: "AO", + avatarUrl: "https://example.com/avatar.png", + threadId: "111", + retries: 5, + retryDelayMs: 3000, + }); + }); + + it("shows status without writing config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/123/secret +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord", "--status"]); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting Discord notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 60ffeb6c7..84e076ce9 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -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 | 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 | 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; +const recordedEvents = (): Array> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + 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 & Pick): Session { + function makeFakeSession( + overrides: Partial & Pick, + ): Session { return { status: "spawning", activity: null, diff --git a/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts b/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts new file mode 100644 index 000000000..ed0dfd3e1 --- /dev/null +++ b/packages/cli/__tests__/commands/start-stop-instrumentation.test.ts @@ -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(); + 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> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +function buildProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerStart(program); + registerStop(program); + return program; +} + +describe("ao stop — activity events", () => { + let exitSpy: ReturnType; + + 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(); + 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(); + 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(); + 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(); + 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(); + 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; + + 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", + }), + }), + ); + }); +}); diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 60a247c8e..b821d3637 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -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 = {}): Record> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + /** 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", diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index e9af2024a..7e7ab90b4 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -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(); + const actual = await importOriginal(); 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 | 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( diff --git a/packages/cli/__tests__/commands/update-instrumentation.test.ts b/packages/cli/__tests__/commands/update-instrumentation.test.ts new file mode 100644 index 000000000..30fb22156 --- /dev/null +++ b/packages/cli/__tests__/commands/update-instrumentation.test.ts @@ -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(); + return { + ...actual, + getGlobalConfigPath: () => "/tmp/__ao_update_instrumentation_no_global_config__", + recordActivityEvent: vi.fn(), + }; +}); + +import { registerUpdate } from "../../src/commands/update.js"; + +const recordedEvents = (): Array> => + vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +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", + }), + }), + ); + }); +}); diff --git a/packages/cli/__tests__/lib/notify-test.test.ts b/packages/cli/__tests__/lib/notify-test.test.ts new file mode 100644 index 000000000..f93de3d18 --- /dev/null +++ b/packages/cli/__tests__/lib/notify-test.test.ts @@ -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 { + 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 | 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(); + } + }); +}); diff --git a/packages/cli/__tests__/lib/project-supervisor.test.ts b/packages/cli/__tests__/lib/project-supervisor.test.ts index 5e8cce679..6f9ce0319 100644 --- a/packages/cli/__tests__/lib/project-supervisor.test.ts +++ b/packages/cli/__tests__/lib/project-supervisor.test.ts @@ -9,6 +9,12 @@ const mockSetHealth = vi.fn(); const activeWorkers = new Set(); 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 ` 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; diff --git a/packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts b/packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts new file mode 100644 index 000000000..149eb9135 --- /dev/null +++ b/packages/cli/__tests__/lib/resolve-project-instrumentation.test.ts @@ -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(); + 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> => + vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +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(); + 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); + 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"); + }); +}); diff --git a/packages/cli/__tests__/lib/shutdown.test.ts b/packages/cli/__tests__/lib/shutdown.test.ts new file mode 100644 index 000000000..6ff859cc5 --- /dev/null +++ b/packages/cli/__tests__/lib/shutdown.test.ts @@ -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(); + 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> => + vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); + +const flushAsync = async (): Promise => { + // 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; + 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(); + } + }); +}); diff --git a/packages/cli/__tests__/program.test.ts b/packages/cli/__tests__/program.test.ts index e854a2b03..b33691c1d 100644 --- a/packages/cli/__tests__/program.test.ts +++ b/packages/cli/__tests__/program.test.ts @@ -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); + }); }); diff --git a/packages/cli/__tests__/scripts/postinstall.test.ts b/packages/cli/__tests__/scripts/postinstall.test.ts new file mode 100644 index 000000000..9d277e854 --- /dev/null +++ b/packages/cli/__tests__/scripts/postinstall.test.ts @@ -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", + }); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 99bd6290d..803046b21 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index e1ba54f50..15817b99a 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -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 { - const activeNotifierNames = config.defaults?.notifiers ?? []; - const targets = new Map>(); + 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", target.reference) ?? - registry.get("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); } } diff --git a/packages/cli/src/commands/events.ts b/packages/cli/src/commands/events.ts index 87430d2a3..bbb72a7bd 100644 --- a/packages/cli/src/commands/events.ts +++ b/packages/cli/src/commands/events.ts @@ -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 ", "Filter by project ID") .option("-s, --session ", "Filter by session ID") - .option("-t, --type ", "Filter by event kind (e.g. session.spawned, lifecycle.transition)") + .option( + "-t, --type ", + "Filter by event kind (e.g. session.spawned, lifecycle.transition)", + ) + .option("--kind ", "Alias for --type") + .option("--source ", "Filter by event source (e.g. lifecycle, recovery, api)") .option("--log-level ", "Filter by log level (debug, info, warn, error)") .option("--since ", "Show events from last N minutes/hours/days (e.g. 30m, 2h, 1d)") .option("-n, --limit ", "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, diff --git a/packages/cli/src/commands/migrate-storage.ts b/packages/cli/src/commands/migrate-storage.ts index 04f450439..4e90bbd28 100644 --- a/packages/cli/src/commands/migrate-storage.ts +++ b/packages/cli/src/commands/migrate-storage.ts @@ -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)), ); diff --git a/packages/cli/src/commands/notify.ts b/packages/cli/src/commands/notify.ts new file mode 100644 index 000000000..e916d24cd --- /dev/null +++ b/packages/cli/src/commands/notify.ts @@ -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 { + 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 ", "Demo template to send", "basic") + .option("--to ", "Comma-separated notifier refs to target") + .option("--all", "Send to all configured, default, and routed notifier refs") + .option("--route ", "Send through a priority route") + .option("--actions", "Send demo actions when supported") + .option("--message ", "Override the notification message") + .option("--session ", "Override the demo session id") + .option("--project ", "Override the demo project id") + .option("--priority ", "Override the event priority") + .option("--type ", "Override the event type") + .option("--data ", "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); + } + }); +} diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index fa2adcc1e..b5c5da2ab 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -509,38 +509,51 @@ export function registerPlugin(program: Command): void { ) .option( "--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 ", + "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") diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 5dfc35c21..9c0a97228 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -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)}`, diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts new file mode 100644 index 000000000..488b24032 --- /dev/null +++ b/packages/cli/src/commands/review.ts @@ -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 = 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("", "Worker session ID") + .option("--summary ", "Summary to store on the review run") + .option("--status ", "Initial run status (defaults to queued)") + .option("--execute", "Execute the review run immediately") + .option("--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 ", "Review run ID or reviewer session ID") + .option("--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("", "Review run ID or reviewer session ID") + .option("-p, --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); + } + }); +} diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index fa03f465d..9be56d4f9 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -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 { - 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 { - 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) ?? {}; - - // 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 = {}; - if (existsSync(openclawJsonPath)) { - const raw = readFileSync(openclawJsonPath, "utf-8"); - config = JSON.parse(raw) as Record; - } 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 | 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": "", - "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: ""'); - 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 ", "Number of latest notifications to retain for the dashboard") + .option("--routing-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 ", "Desktop backend: auto | ao-app | terminal-notifier | osascript") + .option("--refresh", "Refresh/reconfigure desktop notifier config") + .option("--dashboard-url ", "Dashboard URL to open from notifications") + .option("--app-path ", "Custom AO Notifier.app install path") + .option("--routing-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 ", "Webhook URL") + .option("--auth-token ", "Bearer token to store in webhook Authorization header") + .option("--routing-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 ", "Slack incoming webhook URL") + .option( + "--channel ", + "Optional channel name; must match the channel selected for the webhook URL", + ) + .option("--username ", "Slack display name for AO messages") + .option("--routing-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 ", "Discord incoming webhook URL") + .option("--username ", "Discord display name for AO messages") + .option("--avatar-url ", "Discord avatar image URL for AO messages") + .option("--thread-id ", "Discord thread id to post into") + .option("--retries ", "Retry count for rate limits, network errors, and 5xx") + .option("--retry-delay-ms ", "Base retry delay in milliseconds") + .option("--routing-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 ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-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 ", "Slack channel name or channel id for scriptable Slack setup") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option( + "--connected-account-id ", + "Existing Composio Slack connected account id for scriptable Slack setup", + ) + .option( + "--wait-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 ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for the Slack connected account") + .option("--channel ", "Slack channel name or channel id") + .option("--connected-account-id ", "Existing Composio Slack connected account id") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--wait-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 ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for tool execution") + .option("--webhook-url ", "Discord webhook URL") + .option("--connected-account-id ", "Existing Composio Discord webhook connected account id") + .option("--routing-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 ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for the Discord connected account") + .option("--channel-id ", "Discord channel id") + .option("--bot-token ", "Discord bot token used once to create the Composio account") + .option("--connected-account-id ", "Existing Composio Discord bot connected account id") + .option("--routing-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 ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for the Gmail connected account") + .option("--email-to ", "Recipient email address for AO notifications") + .option("--connect", "Print a Composio Gmail connect URL when no account exists") + .option("--auth-config-id ", "Existing Composio Gmail auth config id for --connect") + .option("--connected-account-id ", "Existing Composio Gmail connected account id") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--wait-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 ", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)") - .option("--token ", "OpenClaw hooks auth token") .option( - "--routing-preset ", - "OpenClaw routing preset: urgent-only | urgent-action | all", + "--token ", + "Remote/manual fallback token; local setup should read hooks.token from OpenClaw config", ) + .option("--openclaw-config-path ", "OpenClaw config path that contains hooks.token") + .option("--routing-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 { - 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 { + await runOpenClawSetupAction(opts); } diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index fed2f0585..cb2730b43 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -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 ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-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 ", "Initial prompt/instructions for the agent (use instead of an issue)") + .option( + "--prompt ", + "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); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 015257aa2..844eea724 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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 { ...(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(); // 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 { diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 16e063bcc..72b3327f9 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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 = 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" : ""}` + : "") : ""), ), ); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 8364d613d..8e3d37dd9 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -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 { // would overwrite cache.channel before we can read it. const previousChannel = readCachedUpdateInfo(method)?.channel; - const info = await checkForUpdate({ force: true, channel }); + let info: Awaited>; + 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 { 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); } } diff --git a/packages/cli/src/lib/composio-setup.ts b/packages/cli/src/lib/composio-setup.ts new file mode 100644 index 000000000..ab565f171 --- /dev/null +++ b/packages/cli/src/lib/composio-setup.ts @@ -0,0 +1,4531 @@ +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, + ensureNotifierDefault, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + routingLabel, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +const SLACK_TOOLKIT = "slack"; +const DISCORD_TOOLKIT = "discordbot"; +const GMAIL_TOOLKIT = "gmail"; +const DISCORD_TOOL_VERSION = "20260429_01"; +const GMAIL_TOOL_VERSION = "20260506_01"; +const COMPOSIO_NOTIFIER = "composio"; +const COMPOSIO_SLACK_NOTIFIER = "composio-slack"; +const COMPOSIO_DISCORD_WEBHOOK_NOTIFIER = "composio-discord"; +const COMPOSIO_DISCORD_BOT_NOTIFIER = "composio-discord-bot"; +const COMPOSIO_MAIL_NOTIFIER = "composio-mail"; +const DEFAULT_COMPOSIO_USER_ID = "aoagent"; +const GMAIL_SEND_TOOL = "GMAIL_SEND_EMAIL"; +const COMPOSIO_DASHBOARD_URL = "https://app.composio.dev"; +const DISCORD_APP_URL = "https://discord.com/app"; +const DISCORD_DEVELOPER_PORTAL_URL = "https://discord.com/developers/applications"; +const DISCORD_WEBHOOK_DOCS_URL = + "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"; + +export class ComposioSetupError extends Error { + constructor( + message: string, + public readonly exitCode: number = 1, + ) { + super(message); + this.name = "ComposioSetupError"; + } +} + +export interface ComposioSetupOptions { + apiKey?: string; + userId?: string; + channel?: string; + connectedAccountId?: string; + webhookUrl?: string; + channelId?: string; + botToken?: string; + emailTo?: string; + authConfigId?: string; + connect?: boolean; + slack?: boolean; + discordWebhook?: boolean; + discordBot?: boolean; + gmail?: boolean; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + waitMs?: string; + routingPreset?: string; +} + +export interface ComposioDiscordWebhookSetupOptions { + apiKey?: string; + userId?: string; + webhookUrl?: string; + connectedAccountId?: string; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + routingPreset?: string; +} + +export interface ComposioDiscordBotSetupOptions { + apiKey?: string; + userId?: string; + channelId?: string; + botToken?: string; + connectedAccountId?: string; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + routingPreset?: string; +} + +export interface ComposioMailSetupOptions { + apiKey?: string; + userId?: string; + emailTo?: string; + authConfigId?: string; + connectedAccountId?: string; + connect?: boolean; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + waitMs?: string; + routingPreset?: string; +} + +type ComposioAppChoice = "slack" | "discord-webhook" | "discord-bot" | "gmail"; + +interface ResolvedApiKey { + apiKey: string; + shouldWriteApiKey: boolean; + sourceLabel: string; +} + +interface ConnectedAccount { + id: string; + status?: string; + statusReason?: string | null; + toolkit?: { slug?: string }; + authConfig?: { id?: string; name?: string }; + alias?: string | null; + isDisabled?: boolean; + scopes?: string[]; +} + +interface AuthConfigSummary { + id: string; + toolkit?: { slug?: string }; + toolAccessConfig?: { + toolsAvailableForExecution?: string[]; + toolsForConnectedAccountCreation?: string[]; + }; + restrictToFollowingTools?: string[]; +} + +interface ConnectionRequest { + id?: string; + redirectUrl?: string; + waitForConnection?: (timeout?: number) => Promise; +} + +interface ComposioSetupClient { + connectedAccounts: { + list: (query?: Record) => Promise; + get?: (id: string) => Promise; + link?: ( + userId: string, + authConfigId: string, + options?: Record, + ) => Promise; + initiate?: ( + userId: string, + authConfigId: string, + options?: Record, + ) => Promise; + waitForConnection?: (id: string, timeout?: number) => Promise; + }; + authConfigs?: { + list?: (query?: Record) => Promise; + create?: (toolkit: string, options?: Record) => Promise; + get?: (id: string) => Promise; + retrieve?: (id: string) => Promise; + }; + toolkits?: { + authorize?: (userId: string, toolkitSlug: string, authConfigId?: string) => Promise; + }; +} + +interface ResolvedComposioSetup { + apiKey: string; + shouldWriteApiKey: boolean; + userId: string; + targetName?: string; + channel?: string; + connectedAccountId?: string; + connectionUrl?: string; + routingPreset?: NotifierRoutingPreset; +} + +interface ResolvedDiscordSetup { + apiKey: string; + shouldWriteApiKey: boolean; + userId: string; + mode: "webhook" | "bot"; + targetName: string; + webhookUrl?: string; + channelId?: string; + connectedAccountId?: string; + routingPreset?: NotifierRoutingPreset; +} + +interface ResolvedMailSetup { + apiKey: string; + shouldWriteApiKey: boolean; + userId: string; + emailTo?: string; + connectedAccountId?: string; + connectionUrl?: string; + targetName?: string; + routingPreset?: NotifierRoutingPreset; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +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 resolveComposioRoutingPreset( + value: string | undefined, +): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "Composio") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new ComposioSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function routingReviewLabel(preset: NotifierRoutingPreset | undefined): string { + return preset ? routingLabel(preset) : "unchanged"; +} + +function scopeArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string"); + } + if (typeof value === "string") { + return value + .split(/\s+/) + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function getExistingComposioConfig(rawConfig: Record): Record { + return getExistingNotifierConfig(rawConfig, COMPOSIO_NOTIFIER); +} + +function getExistingNotifierConfig( + rawConfig: Record, + notifierName: string, +): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = isRecord(notifiers[notifierName]) ? notifiers[notifierName] : {}; + return existing; +} + +function resolveApiKeyCandidate( + opts: { apiKey?: string }, + existing: Record, +): ResolvedApiKey | undefined { + const optionKey = stringValue(opts.apiKey); + if (optionKey) { + return { + apiKey: optionKey, + shouldWriteApiKey: true, + sourceLabel: "command option", + }; + } + + const envKey = stringValue(process.env.COMPOSIO_API_KEY); + if (envKey) { + return { + apiKey: envKey, + shouldWriteApiKey: false, + sourceLabel: "COMPOSIO_API_KEY", + }; + } + + const existingKey = stringValue(existing["composioApiKey"]); + if (existingKey && !existingKey.includes("${")) { + return { + apiKey: existingKey, + shouldWriteApiKey: true, + sourceLabel: "agent-orchestrator.yaml", + }; + } + + return undefined; +} + +function resolveApiKey( + opts: { apiKey?: string }, + existing: Record, +): { apiKey?: string; shouldWriteApiKey: boolean } { + const candidate = resolveApiKeyCandidate(opts, existing); + return { + apiKey: candidate?.apiKey, + shouldWriteApiKey: candidate?.shouldWriteApiKey ?? false, + }; +} + +function resolveUserId(opts: { userId?: string }, existing: Record): string { + return ( + stringValue(opts.userId) ?? + stringValue(existing["userId"]) ?? + stringValue(existing["entityId"]) ?? + stringValue(process.env.COMPOSIO_USER_ID) ?? + stringValue(process.env.COMPOSIO_ENTITY_ID) ?? + DEFAULT_COMPOSIO_USER_ID + ); +} + +function isComposioSetupClient(value: unknown): value is ComposioSetupClient { + return ( + isRecord(value) && + isRecord(value["connectedAccounts"]) && + typeof value["connectedAccounts"]["list"] === "function" + ); +} + +async function loadComposioClient(apiKey: string): Promise { + const mod = (await import("@composio/core")) as unknown as Record; + const ComposioClass = (mod.Composio ?? + (mod.default as Record | undefined)?.Composio ?? + mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined; + + if (typeof ComposioClass !== "function") { + throw new ComposioSetupError("Could not find Composio class in @composio/core module."); + } + + const client = new ComposioClass({ apiKey }); + if (!isComposioSetupClient(client)) { + throw new ComposioSetupError("Composio SDK client does not expose connectedAccounts.list()."); + } + + return client; +} + +function toConnectedAccount(value: unknown): ConnectedAccount | null { + if (!isRecord(value)) return null; + const id = stringValue(value["id"]); + if (!id) return null; + const data = isRecord(value["data"]) ? value["data"] : {}; + const params = isRecord(value["params"]) ? value["params"] : {}; + const state = isRecord(value["state"]) ? value["state"] : {}; + const stateVal = isRecord(state["val"]) ? state["val"] : {}; + + return { + id, + status: stringValue(value["status"]), + statusReason: stringValue(value["statusReason"]) ?? stringValue(value["status_reason"]) ?? null, + toolkit: isRecord(value["toolkit"]) + ? { slug: stringValue(value["toolkit"]["slug"]) } + : undefined, + authConfig: isRecord(value["authConfig"]) + ? { + id: stringValue(value["authConfig"]["id"]), + name: stringValue(value["authConfig"]["name"]), + } + : undefined, + alias: stringValue(value["alias"]) ?? null, + isDisabled: value["isDisabled"] === true || value["is_disabled"] === true, + scopes: [ + ...scopeArray(data["scope"]), + ...scopeArray(data["scopes"]), + ...scopeArray(params["scope"]), + ...scopeArray(params["scopes"]), + ...scopeArray(stateVal["scope"]), + ...scopeArray(stateVal["scopes"]), + ], + }; +} + +function toAuthConfigSummary(value: unknown): AuthConfigSummary | null { + if (!isRecord(value)) return null; + const id = stringValue(value["id"]); + if (!id) return null; + const toolkit = isRecord(value["toolkit"]) ? value["toolkit"] : {}; + const toolAccessConfig = isRecord(value["toolAccessConfig"]) + ? value["toolAccessConfig"] + : isRecord(value["tool_access_config"]) + ? value["tool_access_config"] + : {}; + + return { + id, + toolkit: isRecord(toolkit) ? { slug: stringValue(toolkit["slug"]) } : undefined, + toolAccessConfig: { + toolsAvailableForExecution: asStringArray( + toolAccessConfig["toolsAvailableForExecution"] ?? + toolAccessConfig["tools_available_for_execution"], + ), + toolsForConnectedAccountCreation: asStringArray( + toolAccessConfig["toolsForConnectedAccountCreation"] ?? + toolAccessConfig["tools_for_connected_account_creation"], + ), + }, + restrictToFollowingTools: asStringArray( + value["restrictToFollowingTools"] ?? value["restrict_to_following_tools"], + ), + }; +} + +function accountsFromListResult(result: unknown): ConnectedAccount[] { + if (Array.isArray(result)) + return result.map(toConnectedAccount).filter((a): a is ConnectedAccount => a !== null); + if (isRecord(result) && Array.isArray(result["items"])) { + return result["items"].map(toConnectedAccount).filter((a): a is ConnectedAccount => a !== null); + } + if (isRecord(result) && Array.isArray(result["data"])) { + return result["data"].map(toConnectedAccount).filter((a): a is ConnectedAccount => a !== null); + } + return []; +} + +function authConfigsFromListResult(result: unknown): AuthConfigSummary[] { + if (Array.isArray(result)) + return result.map(toAuthConfigSummary).filter((a): a is AuthConfigSummary => a !== null); + if (isRecord(result) && Array.isArray(result["items"])) { + return result["items"] + .map(toAuthConfigSummary) + .filter((a): a is AuthConfigSummary => a !== null); + } + if (isRecord(result) && Array.isArray(result["data"])) { + return result["data"] + .map(toAuthConfigSummary) + .filter((a): a is AuthConfigSummary => a !== null); + } + return []; +} + +function isActive(account: ConnectedAccount): boolean { + if (account.isDisabled) return false; + return !account.status || account.status.toUpperCase() === "ACTIVE"; +} + +function isToolkit(account: ConnectedAccount, toolkit: string): boolean { + return !account.toolkit?.slug || account.toolkit.slug.toLowerCase() === toolkit; +} + +function hasGmailNotifyScopes(account: ConnectedAccount): boolean { + const scopes = new Set(account.scopes ?? []); + if (scopes.has("https://mail.google.com/")) return true; + const canSend = scopes.has("https://www.googleapis.com/auth/gmail.send"); + const canReadProfile = + scopes.has("https://www.googleapis.com/auth/gmail.metadata") || + scopes.has("https://www.googleapis.com/auth/gmail.readonly") || + scopes.has("https://www.googleapis.com/auth/gmail.modify"); + return canSend && canReadProfile; +} + +function authConfigAllowsGmailSend(config: AuthConfigSummary): boolean { + const tools = [ + ...(config.toolAccessConfig?.toolsForConnectedAccountCreation ?? []), + ...(config.toolAccessConfig?.toolsAvailableForExecution ?? []), + ...(config.restrictToFollowingTools ?? []), + ]; + return tools.includes(GMAIL_SEND_TOOL); +} + +async function withConnectedAccountDetails( + client: ComposioSetupClient, + account: ConnectedAccount, +): Promise { + if (!client.connectedAccounts.get) return account; + const detailed = toConnectedAccount(await client.connectedAccounts.get(account.id)); + return detailed ?? account; +} + +async function listActiveSlackAccounts( + client: ComposioSetupClient, + userId: string, +): Promise { + return listActiveToolkitAccounts(client, userId, SLACK_TOOLKIT); +} + +async function listActiveGmailAccounts( + client: ComposioSetupClient, + userId: string, +): Promise { + return listActiveToolkitAccounts(client, userId, GMAIL_TOOLKIT); +} + +async function listUsableGmailAccounts( + client: ComposioSetupClient, + userId: string, +): Promise { + const accounts = await listActiveGmailAccounts(client, userId); + const detailed = await Promise.all( + accounts.map((account) => withConnectedAccountDetails(client, account)), + ); + const usable: ConnectedAccount[] = []; + for (const account of detailed) { + if (await accountCanSendGmail(client, account)) { + usable.push(account); + } + } + return usable; +} + +async function listActiveToolkitAccounts( + client: ComposioSetupClient, + userId: string, + toolkit: string, +): Promise { + const result = await client.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + statuses: ["ACTIVE"], + limit: 25, + }); + return accountsFromListResult(result).filter( + (account) => isActive(account) && isToolkit(account, toolkit), + ); +} + +async function verifyConnectedAccount( + client: ComposioSetupClient, + userId: string, + connectedAccountId: string, +): Promise { + return verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + SLACK_TOOLKIT, + "Slack", + () => listActiveSlackAccounts(client, userId), + ); +} + +async function verifyConnectedAccountForToolkit( + client: ComposioSetupClient, + userId: string, + connectedAccountId: string, + toolkit: string, + label: string, + fallbackList?: () => Promise, +): Promise { + const account = client.connectedAccounts.get + ? toConnectedAccount(await client.connectedAccounts.get(connectedAccountId)) + : ((await fallbackList?.())?.find((candidate) => candidate.id === connectedAccountId) ?? null); + + if (!account) { + throw new ComposioSetupError( + `Could not find Composio connected account ${connectedAccountId} for user ${userId}.`, + ); + } + if (!isToolkit(account, toolkit)) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is not a ${label} account.`, + ); + } + if (!isActive(account)) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is not ACTIVE (status: ${account.status ?? "unknown"}).`, + ); + } + return account; +} + +async function getAuthConfig( + client: ComposioSetupClient, + authConfigId: string, +): Promise { + const result = client.authConfigs?.get + ? await client.authConfigs.get(authConfigId) + : client.authConfigs?.retrieve + ? await client.authConfigs.retrieve(authConfigId) + : null; + return toAuthConfigSummary(result); +} + +async function accountCanSendGmail( + client: ComposioSetupClient, + account: ConnectedAccount, +): Promise { + if (hasGmailNotifyScopes(account)) return true; + const authConfigId = account.authConfig?.id; + if (!authConfigId) return false; + const authConfig = await getAuthConfig(client, authConfigId); + return authConfig ? authConfigAllowsGmailSend(authConfig) : false; +} + +async function resolveGmailConnectAuthConfigId( + client: ComposioSetupClient, + explicitAuthConfigId: string | undefined, + nonInteractive: boolean, +): Promise { + if (explicitAuthConfigId) { + const config = await getAuthConfig(client, explicitAuthConfigId); + if (config?.toolkit?.slug && config.toolkit.slug.toLowerCase() !== GMAIL_TOOLKIT) { + throw new ComposioSetupError(`Auth config ${explicitAuthConfigId} is not a Gmail config.`); + } + if (config && !authConfigAllowsGmailSend(config)) { + console.log( + chalk.yellow( + `Auth config ${explicitAuthConfigId} does not explicitly list ${GMAIL_SEND_TOOL}; creating the link anyway.`, + ), + ); + } + return explicitAuthConfigId; + } + + if (!client.authConfigs?.list) { + throw new ComposioSetupError( + "Composio SDK client does not expose authConfigs.list(); pass --auth-config-id.", + ); + } + + const configs = authConfigsFromListResult( + await client.authConfigs.list({ toolkit: GMAIL_TOOLKIT }), + ).filter( + (config) => !config.toolkit?.slug || config.toolkit.slug.toLowerCase() === GMAIL_TOOLKIT, + ); + const sendConfigs = configs.filter(authConfigAllowsGmailSend); + const candidates = sendConfigs.length > 0 ? sendConfigs : configs; + + if (candidates.length === 0) { + throw new ComposioSetupError( + "No Composio Gmail auth config found. Create/connect Gmail in Composio, or rerun with --auth-config-id ac_...", + ); + } + + if (sendConfigs.length === 0) { + console.log( + chalk.yellow( + `No Gmail auth config explicitly lists ${GMAIL_SEND_TOOL}; using an existing Gmail auth config anyway.`, + ), + ); + } + + return (await chooseAuthConfig(candidates, nonInteractive, "Gmail")).id; +} + +async function chooseAccount( + accounts: ConnectedAccount[], + nonInteractive: boolean, + label = "Slack", +): Promise { + if (accounts.length === 1) return accounts[0]!; + + if (nonInteractive) { + throw new ComposioSetupError( + `Multiple active ${label} connected accounts found. Re-run with --connected-account-id.\n` + + accounts.map((account) => ` - ${account.id}`).join("\n"), + ); + } + + const clack = await import("@clack/prompts"); + const selected = await clack.select({ + message: `Select the ${label} connected account AO should use:`, + options: accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + }); + + if (clack.isCancel(selected)) { + throw new ComposioSetupError("Setup cancelled.", 0); + } + + return accounts.find((account) => account.id === selected)!; +} + +async function chooseAuthConfig( + configs: AuthConfigSummary[], + nonInteractive: boolean, + label: string, +): Promise { + if (configs.length === 1) return configs[0]!; + + if (nonInteractive) { + throw new ComposioSetupError( + `Multiple ${label} auth configs found. Re-run with --auth-config-id.\n` + + configs.map((config) => ` - ${config.id}`).join("\n"), + ); + } + + const clack = await import("@clack/prompts"); + const selected = await clack.select({ + message: `Select the ${label} auth config AO should use:`, + options: configs.map((config) => ({ + value: config.id, + label: config.id, + })), + }); + + if (clack.isCancel(selected)) { + throw new ComposioSetupError("Setup cancelled.", 0); + } + + return configs.find((config) => config.id === selected)!; +} + +function toConnectionRequest(value: unknown): ConnectionRequest { + if (!isRecord(value)) return {}; + return { + id: stringValue(value["id"]), + redirectUrl: stringValue(value["redirectUrl"]), + waitForConnection: + typeof value["waitForConnection"] === "function" + ? (value["waitForConnection"] as (timeout?: number) => Promise) + : undefined, + }; +} + +async function resolveManagedAuthConfigId( + client: ComposioSetupClient, + toolkit: string, + label: string, + name: string, + options: { + scopes?: readonly string[]; + toolsForConnectedAccountCreation?: string[]; + existingAuthConfigPredicate?: (config: AuthConfigSummary) => boolean; + forceCreate?: boolean; + } = {}, +): Promise { + if (!options.forceCreate) { + const existing = client.authConfigs?.list + ? authConfigsFromListResult(await client.authConfigs.list({ toolkit })).find( + (config) => + (!config.toolkit?.slug || config.toolkit.slug.toLowerCase() === toolkit) && + (!options.existingAuthConfigPredicate || options.existingAuthConfigPredicate(config)), + )?.id + : undefined; + if (existing) return existing; + } + + if (!client.authConfigs?.create) { + throw new ComposioSetupError( + `Composio SDK client does not expose authConfigs.create(); connect ${label} in Composio and pass --connected-account-id.`, + ); + } + + const created = await client.authConfigs.create(toolkit, { + type: "use_composio_managed_auth", + name, + ...(options.scopes ? { credentials: { scopes: [...options.scopes] } } : {}), + ...(options.toolsForConnectedAccountCreation + ? { + toolAccessConfig: { + toolsForConnectedAccountCreation: options.toolsForConnectedAccountCreation, + }, + } + : {}), + }); + const createdId = isRecord(created) ? stringValue(created["id"]) : undefined; + if (!createdId) { + throw new ComposioSetupError(`Could not create a Composio ${label} auth config.`); + } + + return createdId; +} + +async function createConnectionRequest( + client: ComposioSetupClient, + userId: string, + waitMs: number, +): Promise<{ account?: ConnectedAccount; url?: string }> { + return createManagedOAuthConnectionRequest( + client, + userId, + SLACK_TOOLKIT, + "Slack", + "Slack Auth Config", + waitMs, + ); +} + +async function createManagedOAuthConnectionRequest( + client: ComposioSetupClient, + userId: string, + toolkit: string, + label: string, + authConfigName: string, + waitMs: number, + options: { + authConfigId?: string; + scopes?: readonly string[]; + toolsForConnectedAccountCreation?: string[]; + existingAuthConfigPredicate?: (config: AuthConfigSummary) => boolean; + forceCreateAuthConfig?: boolean; + } = {}, +): Promise<{ account?: ConnectedAccount; url?: string }> { + let request: ConnectionRequest; + + if (client.connectedAccounts.link) { + const authConfigId = + options.authConfigId ?? + (await resolveManagedAuthConfigId(client, toolkit, label, authConfigName, { + scopes: options.scopes, + toolsForConnectedAccountCreation: options.toolsForConnectedAccountCreation, + existingAuthConfigPredicate: options.existingAuthConfigPredicate, + forceCreate: options.forceCreateAuthConfig, + })); + request = toConnectionRequest( + await client.connectedAccounts.link(userId, authConfigId, { allowMultiple: true }), + ); + } else if (client.toolkits?.authorize) { + request = toConnectionRequest( + await client.toolkits.authorize(userId, toolkit, options.authConfigId), + ); + } else { + throw new ComposioSetupError( + `Composio SDK client does not expose connectedAccounts.link(); connect ${label} in Composio and pass --connected-account-id.`, + ); + } + + if (request.redirectUrl) { + console.log(chalk.cyan(`Open this Composio ${label} connect URL: ${request.redirectUrl}`)); + } + + if (!request.id && !request.waitForConnection) { + return { url: request.redirectUrl }; + } + + try { + const connected = request.waitForConnection + ? await request.waitForConnection(waitMs) + : await client.connectedAccounts.waitForConnection?.(request.id!, waitMs); + const account = toConnectedAccount(connected); + return account ? { account, url: request.redirectUrl } : { url: request.redirectUrl }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(chalk.yellow(`Connection did not complete yet: ${message}`)); + return { url: request.redirectUrl }; + } +} + +function parseWaitMs(value: string | undefined): number { + if (!value) return 60_000; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new ComposioSetupError("--wait-ms must be a non-negative number."); + } + return parsed; +} + +function channelConfig(channel: string | undefined): Record { + const value = stringValue(channel); + if (!value) return {}; + if (/^[CGD][A-Z0-9]{8,}$/.test(value)) { + return { channelId: value }; + } + return { channelName: value }; +} + +function shouldUseInteractiveComposioHub( + opts: ComposioSetupOptions, + nonInteractive: boolean, +): boolean { + if (nonInteractive || opts.status) return false; + if (getDirectComposioAppChoice(opts)) return true; + return !( + stringValue(opts.apiKey) || + stringValue(opts.userId) || + stringValue(opts.channel) || + stringValue(opts.connectedAccountId) || + (stringValue(opts.waitMs) && stringValue(opts.waitMs) !== "60000") + ); +} + +function getDirectComposioAppChoice(opts: ComposioSetupOptions): ComposioAppChoice | undefined { + const choices: ComposioAppChoice[] = []; + if (opts.slack) choices.push("slack"); + if (opts.discordWebhook) choices.push("discord-webhook"); + if (opts.discordBot) choices.push("discord-bot"); + if (opts.gmail) choices.push("gmail"); + + if (choices.length > 1) { + throw new ComposioSetupError( + "Choose only one Composio app flag: --slack, --discord-webhook, --discord-bot, or --gmail.", + ); + } + return choices[0]; +} + +function shouldUseInteractiveDedicatedSetup( + opts: { nonInteractive?: boolean; status?: boolean }, + nonInteractive: boolean, +): boolean { + return !nonInteractive && !opts.status; +} + +function cancelInteractiveComposioSetup(clack: ClackPrompts): never { + clack.cancel("Setup cancelled."); + throw new ComposioSetupError("Setup cancelled.", 0); +} + +function printComposioApiKeyInstructions(): void { + console.log(""); + console.log(chalk.bold("Find your Composio API key")); + console.log(` 1. Open ${COMPOSIO_DASHBOARD_URL}`); + console.log(" 2. Open your project settings or developer settings."); + console.log(" 3. Create or copy an API key that can execute tools."); + console.log(""); +} + +function printComposioSlackAccountInfo(): void { + console.log( + chalk.dim( + "AO uses a Composio Slack connected account to execute SLACK_SEND_MESSAGE. The userId groups connected accounts inside your Composio project.", + ), + ); +} + +function printComposioSlackChannelInfo(): void { + console.log( + chalk.dim( + "Slack channel is optional for Composio. If set, AO passes it to SLACK_SEND_MESSAGE; use the channel name without # or a Slack channel id.", + ), + ); +} + +function printComposioSlackReview(resolved: ResolvedComposioSetup, apiKeySource: string): void { + console.log(""); + console.log(chalk.bold("Review Composio Slack setup")); + console.log(" app: Slack"); + console.log(` notifier: ${resolved.targetName ?? COMPOSIO_NOTIFIER}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + console.log(` channel: ${resolved.channel ?? "not set"}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function redactDiscordWebhookUrl(webhookUrl: string | undefined): string { + if (!webhookUrl) return "not configured"; + try { + const parsed = new URL(webhookUrl); + const segments = parsed.pathname.split("/").filter(Boolean); + const webhookIndex = segments.findIndex((segment) => segment === "webhooks"); + if (webhookIndex >= 0 && segments[webhookIndex + 1]) { + return `${parsed.origin}/api/webhooks/${segments[webhookIndex + 1]}/...`; + } + } catch { + // Fall through to generic redaction. + } + return "configured"; +} + +function printComposioDiscordWebhookInfo(): void { + console.log( + chalk.dim( + "AO uses Composio's discordbot toolkit with DISCORDBOT_EXECUTE_WEBHOOK. Webhook mode stores the Discord webhook token as a Composio bearer connected account; no Discord bot invite is required.", + ), + ); +} + +function printDiscordWebhookInstructions(): void { + console.log(""); + console.log(chalk.bold("Create a Discord webhook URL")); + 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 webhook and copy its URL."); + console.log(" 5. Paste the URL here."); + console.log(chalk.dim(`Discord help: ${DISCORD_WEBHOOK_DOCS_URL}`)); + console.log(""); +} + +function printComposioDiscordWebhookReview( + resolved: ResolvedDiscordSetup, + apiKeySource: string, +): void { + console.log(""); + console.log(chalk.bold("Review Composio Discord webhook setup")); + console.log(" app: Discord webhook"); + console.log(` notifier: ${resolved.targetName}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` webhookUrl: ${redactDiscordWebhookUrl(resolved.webhookUrl)}`); + console.log( + ` connectedAccountId: ${resolved.connectedAccountId ?? "will be created from webhook URL"}`, + ); + console.log(` toolVersion: ${DISCORD_TOOL_VERSION}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function printComposioDiscordBotInfo(): void { + console.log( + chalk.dim( + "AO uses Composio's discordbot toolkit with DISCORDBOT_CREATE_MESSAGE. Bot mode requires a Discord channel id and a Composio Discord bot connected account.", + ), + ); +} + +function printDiscordBotInstructions(): void { + console.log(""); + console.log(chalk.bold("Create and invite a Discord bot")); + console.log(` 1. Open ${DISCORD_DEVELOPER_PORTAL_URL}`); + console.log(" 2. Create or select an application, then open Bot."); + console.log(" 3. Create/reset the bot token and keep it available for this setup."); + console.log(" 4. Open OAuth2 > URL Generator."); + console.log(" 5. Select the bot scope and grant View Channel + Send Messages."); + console.log(" 6. Open the generated URL and invite the bot to the target server."); + console.log(" 7. In Discord, enable Developer Mode and copy the target channel ID."); + console.log(""); +} + +function printDiscordChannelIdInstructions(): void { + console.log(""); + console.log(chalk.bold("Find a Discord channel ID")); + console.log(" 1. Open Discord User Settings > Advanced."); + console.log(" 2. Enable Developer Mode."); + console.log(" 3. Right-click the target channel."); + console.log(" 4. Click Copy Channel ID and paste it here."); + console.log(""); +} + +function printComposioDiscordBotReview(resolved: ResolvedDiscordSetup, apiKeySource: string): void { + console.log(""); + console.log(chalk.bold("Review Composio Discord bot setup")); + console.log(" app: Discord bot"); + console.log(` notifier: ${resolved.targetName}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` channelId: ${resolved.channelId ?? "not configured"}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + console.log(` toolVersion: ${DISCORD_TOOL_VERSION}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function printComposioGmailInfo(): void { + console.log( + chalk.dim( + "AO uses Composio's Gmail toolkit with GMAIL_SEND_EMAIL. Gmail mode requires a recipient email and a Gmail connected account with send/profile access.", + ), + ); +} + +function printComposioGmailConnectInfo(): void { + console.log(""); + console.log(chalk.bold("Connect Gmail in Composio")); + console.log(` 1. Open ${COMPOSIO_DASHBOARD_URL}`); + console.log(" 2. Make sure your project has a Gmail auth config with send access."); + console.log(" 3. AO can create a Composio connect link from that existing auth config."); + console.log(" 4. Complete the Google OAuth flow, then return here."); + console.log( + chalk.dim( + "AO does not create Gmail OAuth/auth configs because Google may block unverified or invalid OAuth apps.", + ), + ); + console.log(""); +} + +function printComposioGmailReview(resolved: ResolvedMailSetup, apiKeySource: string): void { + console.log(""); + console.log(chalk.bold("Review Composio Gmail setup")); + console.log(" app: Gmail"); + console.log(` notifier: ${resolved.targetName ?? COMPOSIO_NOTIFIER}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` emailTo: ${resolved.emailTo ?? "not configured"}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + console.log(` toolVersion: ${GMAIL_TOOL_VERSION}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function printComposioAppRequirements(choice: ComposioAppChoice): void { + console.log(""); + if (choice === "discord-webhook") { + console.log(chalk.bold("Composio Discord webhook setup")); + console.log(" Required: Composio API key, userId, Discord webhook URL."); + console.log(" AO creates/stores a Composio connected account from the webhook token."); + console.log(" No Discord bot invite is required for webhook mode."); + console.log(" Current command: ao setup composio-discord --webhook-url "); + } else if (choice === "discord-bot") { + console.log(chalk.bold("Composio Discord bot setup")); + console.log(" Required: Composio API key, userId, Discord channel id."); + console.log(" Also required once: bot token, unless you already have connectedAccountId."); + console.log(" Current command: ao setup composio-discord-bot --channel-id "); + } else if (choice === "gmail") { + console.log(chalk.bold("Composio Gmail setup")); + console.log(" Required: Composio API key, userId, recipient email, Gmail connectedAccountId."); + console.log(" Gmail OAuth/auth config must be usable in Composio with send/profile access."); + console.log(" Current command: ao setup composio-mail --email-to "); + } + console.log( + chalk.dim("This interactive hub currently implements Slack, Discord webhook/bot, and Gmail."), + ); + console.log(""); +} + +async function promptApiKeyInput(clack: ClackPrompts): Promise { + const apiKeyInput = await clack.password({ + message: "Composio API key:", + validate: (value) => { + if (!String(value ?? "").trim()) return "Composio API key is required."; + }, + }); + + if (clack.isCancel(apiKeyInput)) { + cancelInteractiveComposioSetup(clack); + } + + return { + apiKey: String(apiKeyInput).trim(), + shouldWriteApiKey: true, + sourceLabel: "prompt", + }; +} + +async function promptInteractiveComposioApiKey( + clack: ClackPrompts, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const existingKey = resolveApiKeyCandidate(opts, existing); + + while (true) { + const choice = existingKey + ? await clack.select({ + message: `Composio API key is already available from ${existingKey.sourceLabel}.`, + options: [ + { + value: "use-existing", + label: "Use existing API key", + hint: "Keep the configured key", + }, + { + value: "enter-new", + label: "Enter a new API key", + hint: "Store it in this config", + }, + { + value: "show-steps", + label: "Show where to find it", + hint: "Print Composio dashboard steps", + }, + { value: "back", label: "Back", hint: "Return to app choices" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }) + : await clack.select({ + message: "Composio API key is required to list and create connected accounts.", + options: [ + { + value: "enter-new", + label: "Enter API key", + hint: "Store it in this config", + }, + { + value: "show-steps", + label: "Show where to find it", + hint: "Print Composio dashboard steps", + }, + { value: "back", label: "Back", hint: "Return to app choices" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingKey) return existingKey; + if (choice === "show-steps") { + printComposioApiKeyInstructions(); + continue; + } + if (choice === "enter-new") { + return promptApiKeyInput(clack); + } + } +} + +async function promptInteractiveComposioUserId( + clack: ClackPrompts, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const currentUserId = resolveUserId(opts, existing); + console.log( + chalk.dim( + `userId is the Composio user namespace AO uses for tool execution and connected-account lookup. For AO-managed setups, ${DEFAULT_COMPOSIO_USER_ID} is the recommended default.`, + ), + ); + + while (true) { + const choice = await clack.select({ + message: `Composio userId: ${currentUserId}`, + options: [ + { value: "use-current", label: `Use ${currentUserId}`, hint: "Recommended" }, + { value: "change", label: "Change userId", hint: "Use a different Composio user id" }, + { value: "back", label: "Back", hint: "Return to API key" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-current") return currentUserId; + if (choice === "change") { + const nextUserId = await clack.text({ + message: "Composio userId:", + initialValue: currentUserId, + validate: (value) => { + if (!String(value ?? "").trim()) return "Composio userId is required."; + }, + }); + if (clack.isCancel(nextUserId)) { + cancelInteractiveComposioSetup(clack); + } + return String(nextUserId).trim(); + } + } +} + +async function promptManualSlackConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Slack connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyConnectedAccount(client, userId, String(accountId).trim()); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseSlackConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow("No active Slack connected accounts were found for this Composio userId."), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Slack connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Slack account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptAfterSlackConnectLink( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + existingConnectedAccountId: string | undefined, +): Promise { + console.log( + chalk.yellow( + "Slack connection did not complete yet. Open the connect URL above and finish the Composio flow.", + ), + ); + + while (true) { + const next = await clack.select({ + message: "After opening the Composio Slack connect link, what do you want to do?", + options: [ + { + value: "check-active", + label: "I completed the connection", + hint: "Check Composio for active Slack accounts", + }, + { + value: "retry-link", + label: "Generate link again", + hint: "Create a fresh Composio Slack connect URL", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { + value: "back", + label: "Back", + hint: "Return to Slack account options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "retry-link") return "retry-link"; + + if (next === "enter-id") { + const accountId = await promptManualSlackConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (next === "check-active") { + const account = await promptChooseSlackConnectedAccount( + clack, + await listActiveSlackAccounts(client, userId), + ); + if (account !== "back") return account.id; + } + } +} + +async function promptInteractiveSlackAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const existingConnectedAccountId = + stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"]); + printComposioSlackAccountInfo(); + + while (true) { + const options = [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "choose-active", + label: "Choose active Slack account", + hint: "List accounts already connected in Composio", + }, + { + value: "create-link", + label: "Generate Slack connect link", + hint: "Open Composio OAuth link and wait for completion", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ]; + const choice = await clack.select({ + message: "How do you want to choose the Slack connected account?", + options, + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyConnectedAccount(client, userId, existingConnectedAccountId); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + continue; + } + } + + if (choice === "choose-active") { + const account = await promptChooseSlackConnectedAccount( + clack, + await listActiveSlackAccounts(client, userId), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "create-link") { + while (true) { + const connection = await createConnectionRequest(client, userId, parseWaitMs(opts.waitMs)); + if (connection.account) return connection.account.id; + const next = await promptAfterSlackConnectLink( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (next === "retry-link") continue; + if (next !== "back") return next; + break; + } + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualSlackConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + } + } +} + +async function promptInteractiveSlackChannel( + clack: ClackPrompts, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const existingChannel = + stringValue(opts.channel) ?? + stringValue(existing["channelName"]) ?? + stringValue(existing["channelId"]); + printComposioSlackChannelInfo(); + + while (true) { + const choice = await clack.select({ + message: `Slack channel: ${existingChannel ?? "not set"}`, + options: [ + { + value: "use-current", + label: existingChannel ? `Use ${existingChannel}` : "Leave unset", + hint: existingChannel ? "Keep current Slack target override" : "Do not pass channel", + }, + { + value: "change", + label: "Set channel", + hint: "Channel name without #, or a Slack channel id", + }, + ...(existingChannel + ? [{ value: "clear", label: "Clear channel", hint: "Do not pass channel" }] + : []), + { value: "back", label: "Back", hint: "Return to Slack account" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-current") return existingChannel; + if (choice === "clear") return undefined; + if (choice === "change") { + const channel = await clack.text({ + message: "Slack channel name or id:", + placeholder: "iamasx", + initialValue: existingChannel, + }); + if (clack.isCancel(channel)) { + cancelInteractiveComposioSetup(clack); + } + return stringValue(channel); + } + } +} + +async function promptInteractiveComposioSlackReview( + clack: ClackPrompts, + resolved: ResolvedComposioSetup, + apiKeySource: string, +): Promise<"write" | "channel" | "account" | "routing" | "app" | "cancel"> { + printComposioSlackReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Slack config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "channel", label: "Change channel", hint: "Return to channel step" }, + { value: "account", label: "Change Slack account", hint: "Return to account step" }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "channel" | "account" | "routing" | "app" | "cancel"; +} + +async function promptDiscordWebhookUrlInput( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const webhookUrlInput = await clack.text({ + message: "Discord webhook URL:", + placeholder: "https://discord.com/api/webhooks/...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "Discord webhook URL is required."; + try { + parseDiscordWebhookUrl(String(value).trim()); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(webhookUrlInput)) { + cancelInteractiveComposioSetup(clack); + } + + return String(webhookUrlInput).trim(); +} + +async function promptAfterDiscordWebhookInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printDiscordWebhookInstructions(); + + 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 app URL and steps", + }, + { value: "back", label: "Back", hint: "Return to webhook URL options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "show-steps") { + printDiscordWebhookInstructions(); + continue; + } + if (next === "enter-url") { + return promptDiscordWebhookUrlInput(clack, initialValue); + } + } +} + +async function promptInteractiveDiscordWebhookUrl( + clack: ClackPrompts, + existingWebhookUrl: string | undefined, +): Promise { + printComposioDiscordWebhookInfo(); + + while (true) { + const choice = await clack.select({ + message: `Discord webhook URL: ${redactDiscordWebhookUrl(existingWebhookUrl)}`, + options: [ + ...(existingWebhookUrl + ? [ + { + value: "use-existing", + label: "Use existing webhook URL", + hint: redactDiscordWebhookUrl(existingWebhookUrl), + }, + ] + : []), + { + value: "enter-url", + label: "Paste webhook URL", + hint: "Use a Discord incoming webhook URL", + }, + { + value: "show-steps", + label: "Show me how to create one", + hint: "Print Discord webhook creation steps", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingWebhookUrl) return existingWebhookUrl; + if (choice === "enter-url") { + return promptDiscordWebhookUrlInput(clack, existingWebhookUrl); + } + if (choice === "show-steps") { + const result = await promptAfterDiscordWebhookInstructions(clack, existingWebhookUrl); + if (result !== "back") return result; + } + } +} + +async function promptInteractiveComposioDiscordWebhookReview( + clack: ClackPrompts, + resolved: ResolvedDiscordSetup, + apiKeySource: string, +): Promise<"write" | "webhook" | "account" | "routing" | "app" | "cancel"> { + printComposioDiscordWebhookReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Discord webhook config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "webhook", label: "Change webhook URL", hint: "Return to webhook URL step" }, + { + value: "account", + label: "Change connected account", + hint: "Create, choose, or enter a Composio connected account", + }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "webhook" | "account" | "routing" | "app" | "cancel"; +} + +async function promptManualDiscordWebhookConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Discord webhook connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + String(accountId).trim(), + DISCORD_TOOLKIT, + "Discord webhook", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseDiscordWebhookConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow( + "No active Discord webhook connected accounts were found for this Composio userId.", + ), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Discord webhook connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Discord webhook account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptInteractiveDiscordWebhookAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + webhookUrl: string, + existingConnectedAccountId: string | undefined, +): Promise { + while (true) { + const choice = await clack.select({ + message: "How do you want to configure the Composio Discord webhook connected account?", + options: [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "create-account", + label: "Create from webhook URL", + hint: "Store the webhook token in Composio for this userId", + }, + { + value: "choose-active", + label: "Choose active Discord account", + hint: "List discordbot accounts already connected in Composio", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { value: "back", label: "Back", hint: "Return to webhook URL" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + existingConnectedAccountId, + DISCORD_TOOLKIT, + "Discord webhook", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + continue; + } + } + + if (choice === "create-account") { + return resolveDiscordWebhookConnectedAccountId(client, userId, webhookUrl); + } + + if (choice === "choose-active") { + const account = await promptChooseDiscordWebhookConnectedAccount( + clack, + await listActiveToolkitAccounts(client, userId, DISCORD_TOOLKIT), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualDiscordWebhookConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + } + } +} + +function validateDiscordChannelIdInput(value: string): string | undefined { + if (!value.trim()) return "Discord channel id is required."; + if (!/^\d{8,}$/.test(value.trim())) return "Discord channel id must be numeric."; +} + +async function promptDiscordBotChannelIdInput( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const channelIdInput = await clack.text({ + message: "Discord channel ID:", + placeholder: "1234567890", + initialValue, + validate: (value) => validateDiscordChannelIdInput(String(value ?? "")), + }); + + if (clack.isCancel(channelIdInput)) { + cancelInteractiveComposioSetup(clack); + } + + return String(channelIdInput).trim(); +} + +async function promptAfterDiscordChannelIdInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printDiscordChannelIdInstructions(); + + while (true) { + const next = await clack.select({ + message: "After copying the Discord channel ID, what do you want to do?", + options: [ + { value: "enter-id", label: "Paste channel ID", hint: "Continue setup" }, + { + value: "show-steps", + label: "Show steps again", + hint: "Reprint the Developer Mode steps", + }, + { value: "back", label: "Back", hint: "Return to channel options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "show-steps") { + printDiscordChannelIdInstructions(); + continue; + } + if (next === "enter-id") { + return promptDiscordBotChannelIdInput(clack, initialValue); + } + } +} + +async function promptInteractiveDiscordBotChannel( + clack: ClackPrompts, + existingChannelId: string | undefined, +): Promise { + printComposioDiscordBotInfo(); + + while (true) { + const choice = await clack.select({ + message: `Discord channel ID: ${existingChannelId ?? "not configured"}`, + options: [ + ...(existingChannelId + ? [ + { + value: "use-existing", + label: "Use existing channel ID", + hint: existingChannelId, + }, + ] + : []), + { value: "enter-id", label: "Paste channel ID", hint: "Use a Discord channel id" }, + { + value: "show-steps", + label: "Show me how to find it", + hint: "Print Developer Mode channel-id steps", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingChannelId) return existingChannelId; + if (choice === "enter-id") return promptDiscordBotChannelIdInput(clack, existingChannelId); + if (choice === "show-steps") { + const result = await promptAfterDiscordChannelIdInstructions(clack, existingChannelId); + if (result !== "back") return result; + } + } +} + +async function promptManualDiscordBotConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Discord bot connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + String(accountId).trim(), + DISCORD_TOOLKIT, + "Discord Bot", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseDiscordBotConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow("No active Discord bot connected accounts were found for this Composio userId."), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Discord bot connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Discord bot account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptDiscordBotTokenInput( + clack: ClackPrompts, + optionToken: string | undefined, +): Promise { + const envToken = stringValue(process.env.DISCORD_BOT_TOKEN); + if (optionToken || envToken) { + const choice = await clack.select({ + message: "Discord bot token:", + options: [ + ...(optionToken + ? [ + { + value: "use-option", + label: "Use provided bot token", + hint: "Used once; not written to config", + }, + ] + : []), + ...(envToken + ? [ + { + value: "use-env", + label: "Use DISCORD_BOT_TOKEN", + hint: "Use the current environment", + }, + ] + : []), + { value: "paste", label: "Paste bot token", hint: "Used once; not written to config" }, + { value: "back", label: "Back", hint: "Return to account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-option" && optionToken) return optionToken; + if (choice === "use-env" && envToken) return envToken; + } + + printDiscordBotInstructions(); + const token = await clack.password({ + message: "Discord bot token:", + validate: (value) => { + if (!String(value ?? "").trim()) return "Discord bot token is required."; + }, + }); + + if (clack.isCancel(token)) { + cancelInteractiveComposioSetup(clack); + } + + return String(token).trim(); +} + +async function promptInteractiveDiscordBotAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + channelId: string, + existingConnectedAccountId: string | undefined, + optionToken: string | undefined, +): Promise { + while (true) { + const choice = await clack.select({ + message: "How do you want to configure the Composio Discord bot account?", + options: [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "choose-active", + label: "Choose active Discord bot account", + hint: "List active discordbot accounts for this userId", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { + value: "create-account", + label: "Create from bot token", + hint: "Validate channel access and store a Composio connected account", + }, + { value: "back", label: "Back", hint: "Return to channel ID" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + existingConnectedAccountId, + DISCORD_TOOLKIT, + "Discord Bot", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + continue; + } + + if (choice === "choose-active") { + const account = await promptChooseDiscordBotConnectedAccount( + clack, + await listActiveToolkitAccounts(client, userId, DISCORD_TOOLKIT), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualDiscordBotConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (choice === "create-account") { + const token = await promptDiscordBotTokenInput(clack, optionToken); + if (token === "back") continue; + try { + await validateDiscordBotChannelAccess(token, channelId); + return await createDiscordBearerConnectedAccount( + client, + userId, + token, + "Discord Bot Auth Config", + ); + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + } + } +} + +async function promptInteractiveComposioDiscordBotReview( + clack: ClackPrompts, + resolved: ResolvedDiscordSetup, + apiKeySource: string, +): Promise<"write" | "channel" | "account" | "routing" | "app" | "cancel"> { + printComposioDiscordBotReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Discord bot config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "channel", label: "Change channel ID", hint: "Return to channel step" }, + { value: "account", label: "Change bot account", hint: "Return to account step" }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "channel" | "account" | "routing" | "app" | "cancel"; +} + +function validateEmailInput(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return "Recipient email is required."; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return "Enter a valid email address."; +} + +async function promptGmailEmailInput( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const email = await clack.text({ + message: "Recipient email:", + placeholder: "alerts@example.com", + initialValue, + validate: (value) => validateEmailInput(String(value ?? "")), + }); + + if (clack.isCancel(email)) { + cancelInteractiveComposioSetup(clack); + } + + return String(email).trim(); +} + +async function promptInteractiveGmailEmail( + clack: ClackPrompts, + existingEmailTo: string | undefined, +): Promise { + printComposioGmailInfo(); + + while (true) { + const choice = await clack.select({ + message: `Gmail recipient email: ${existingEmailTo ?? "not configured"}`, + options: [ + ...(existingEmailTo + ? [ + { + value: "use-existing", + label: "Use existing recipient", + hint: existingEmailTo, + }, + ] + : []), + { + value: "enter-email", + label: "Enter recipient email", + hint: "AO sends notification emails to this address", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingEmailTo) return existingEmailTo; + if (choice === "enter-email") return promptGmailEmailInput(clack, existingEmailTo); + } +} + +async function verifyUsableGmailConnectedAccount( + client: ComposioSetupClient, + userId: string, + connectedAccountId: string, +): Promise { + const account = await withConnectedAccountDetails( + client, + await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + GMAIL_TOOLKIT, + "Gmail", + () => listActiveGmailAccounts(client, userId), + ), + ); + if (!(await accountCanSendGmail(client, account))) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is missing Gmail send/profile access. Reconnect Gmail in Composio with send access, or use a different Gmail connected account.`, + ); + } + return account; +} + +async function promptManualGmailConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Gmail connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyUsableGmailConnectedAccount( + client, + userId, + String(accountId).trim(), + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseGmailConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow( + "No active Gmail connected accounts with send/profile access were found for this Composio userId.", + ), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Gmail connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Gmail account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptManualGmailAuthConfigId( + clack: ClackPrompts, + client: ComposioSetupClient, + initialValue: string | undefined, +): Promise { + const authConfigId = await clack.text({ + message: "Composio Gmail authConfigId:", + placeholder: "ac_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "authConfigId is required."; + }, + }); + + if (clack.isCancel(authConfigId)) { + cancelInteractiveComposioSetup(clack); + } + + const id = String(authConfigId).trim(); + const config = await getAuthConfig(client, id); + if (config?.toolkit?.slug && config.toolkit.slug.toLowerCase() !== GMAIL_TOOLKIT) { + console.log(chalk.yellow(`Auth config ${id} is not a Gmail config.`)); + return "back"; + } + if (config && !authConfigAllowsGmailSend(config)) { + console.log( + chalk.yellow( + `Auth config ${id} does not explicitly list ${GMAIL_SEND_TOOL}; AO will create the connect link anyway.`, + ), + ); + } + return id; +} + +async function listGmailAuthConfigs(client: ComposioSetupClient): Promise { + if (!client.authConfigs?.list) { + throw new ComposioSetupError( + "Composio SDK client does not expose authConfigs.list(); enter a Gmail authConfigId manually.", + ); + } + return authConfigsFromListResult( + await client.authConfigs.list({ toolkit: GMAIL_TOOLKIT }), + ).filter( + (config) => !config.toolkit?.slug || config.toolkit.slug.toLowerCase() === GMAIL_TOOLKIT, + ); +} + +async function promptChooseGmailAuthConfig( + clack: ClackPrompts, + configs: AuthConfigSummary[], +): Promise { + if (configs.length === 0) { + console.log( + chalk.yellow( + "No Composio Gmail auth configs were found. Create one in Composio or enter authConfigId manually.", + ), + ); + return "back"; + } + + const sendConfigs = configs.filter(authConfigAllowsGmailSend); + const candidates = sendConfigs.length > 0 ? sendConfigs : configs; + if (sendConfigs.length === 0) { + console.log( + chalk.yellow( + `No Gmail auth config explicitly lists ${GMAIL_SEND_TOOL}; showing existing Gmail auth configs anyway.`, + ), + ); + } + + const selected = await clack.select({ + message: "Select the Gmail auth config for the Composio connect link:", + options: [ + ...candidates.map((config) => ({ + value: config.id, + label: config.id, + hint: authConfigAllowsGmailSend(config) ? GMAIL_SEND_TOOL : "Gmail auth config", + })), + { value: "back", label: "Back", hint: "Return to Gmail account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return candidates.find((config) => config.id === selected) ?? "back"; +} + +async function promptInteractiveGmailAuthConfig( + clack: ClackPrompts, + client: ComposioSetupClient, + existingAuthConfigId: string | undefined, +): Promise { + printComposioGmailConnectInfo(); + + while (true) { + const choice = await clack.select({ + message: "How should AO choose the Gmail auth config for the connect link?", + options: [ + ...(existingAuthConfigId + ? [ + { + value: "use-existing", + label: "Use existing authConfigId", + hint: existingAuthConfigId, + }, + ] + : []), + { + value: "choose-existing", + label: "Choose existing Gmail auth config", + hint: "List Gmail auth configs in Composio", + }, + { + value: "enter-id", + label: "Enter authConfigId", + hint: "Use an existing ac_... value", + }, + { value: "back", label: "Back", hint: "Return to Gmail account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingAuthConfigId) return existingAuthConfigId; + + if (choice === "enter-id") { + const id = await promptManualGmailAuthConfigId(clack, client, existingAuthConfigId); + if (id !== "back") return id; + continue; + } + + if (choice === "choose-existing") { + try { + const config = await promptChooseGmailAuthConfig(clack, await listGmailAuthConfigs(client)); + if (config !== "back") return config.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + } + } +} + +async function promptAfterGmailConnectLink( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + existingConnectedAccountId: string | undefined, +): Promise { + console.log( + chalk.yellow( + "Gmail connection did not complete yet. Open the connect URL above and finish the Composio flow.", + ), + ); + + while (true) { + const next = await clack.select({ + message: "After opening the Composio Gmail connect link, what do you want to do?", + options: [ + { + value: "check-active", + label: "I completed the connection", + hint: "Check Composio for usable Gmail accounts", + }, + { + value: "retry-link", + label: "Generate link again", + hint: "Use the same Gmail auth config", + }, + { + value: "change-auth-config", + label: "Change authConfigId", + hint: "Use a different Gmail auth config", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { + value: "back", + label: "Back", + hint: "Return to Gmail account options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "retry-link") return "retry-link"; + if (next === "change-auth-config") return "change-auth-config"; + + if (next === "enter-id") { + const accountId = await promptManualGmailConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (next === "check-active") { + const account = await promptChooseGmailConnectedAccount( + clack, + await listUsableGmailAccounts(client, userId), + ); + if (account !== "back") return account.id; + } + } +} + +async function promptInteractiveGmailAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + opts: ComposioSetupOptions, + existingConnectedAccountId: string | undefined, + existingAuthConfigId: string | undefined, +): Promise { + let authConfigId = existingAuthConfigId; + + while (true) { + const choice = await clack.select({ + message: "How do you want to choose the Gmail connected account?", + options: [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "choose-active", + label: "Choose active Gmail account", + hint: "List accounts already connected in Composio", + }, + { + value: "create-link", + label: "Generate Gmail connect link", + hint: "Use an existing Composio Gmail auth config", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { value: "back", label: "Back", hint: "Return to recipient email" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyUsableGmailConnectedAccount( + client, + userId, + existingConnectedAccountId, + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + continue; + } + + if (choice === "choose-active") { + const account = await promptChooseGmailConnectedAccount( + clack, + await listUsableGmailAccounts(client, userId), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualGmailConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (choice === "create-link") { + while (true) { + if (!authConfigId) { + const selectedAuthConfigId = await promptInteractiveGmailAuthConfig( + clack, + client, + authConfigId, + ); + if (selectedAuthConfigId === "back") break; + authConfigId = selectedAuthConfigId; + } + + const connection = await createManagedOAuthConnectionRequest( + client, + userId, + GMAIL_TOOLKIT, + "Gmail", + "Gmail Auth Config", + parseWaitMs(opts.waitMs), + { authConfigId }, + ); + + if (connection.account) { + try { + const account = await withConnectedAccountDetails(client, connection.account); + if (await accountCanSendGmail(client, account)) return account.id; + console.log( + chalk.yellow( + `Connected Gmail account ${account.id} is missing Gmail send/profile access.`, + ), + ); + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + } + + const next = await promptAfterGmailConnectLink( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (next === "retry-link") continue; + if (next === "change-auth-config") { + authConfigId = undefined; + continue; + } + if (next !== "back") return next; + break; + } + } + } +} + +async function promptInteractiveComposioGmailReview( + clack: ClackPrompts, + resolved: ResolvedMailSetup, + apiKeySource: string, +): Promise<"write" | "email" | "account" | "routing" | "app" | "cancel"> { + printComposioGmailReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Gmail config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "email", label: "Change recipient email", hint: "Return to email step" }, + { value: "account", label: "Change Gmail account", hint: "Return to account step" }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "email" | "account" | "routing" | "app" | "cancel"; +} + +async function confirmComposioSlackConflict( + clack: ClackPrompts, + targetName: string, + existingPlugin: string | undefined, + force: boolean | undefined, +): Promise { + if (!existingPlugin || existingPlugin === "composio" || force) return true; + const replace = await clack.confirm({ + message: `notifiers.${targetName} already uses plugin "${existingPlugin}". Replace it with Composio Slack?`, + initialValue: false, + }); + + if (clack.isCancel(replace)) { + cancelInteractiveComposioSetup(clack); + } + if (!replace) { + console.log(chalk.dim(`Keeping existing notifiers.${targetName} config.`)); + return false; + } + return true; +} + +async function confirmComposioDiscordWebhookConflict( + clack: ClackPrompts, + targetName: string, + existingPlugin: string | undefined, + force: boolean | undefined, + label = "Composio Discord webhook", +): Promise { + if (!existingPlugin || existingPlugin === "composio" || force) return true; + const replace = await clack.confirm({ + message: `notifiers.${targetName} already uses plugin "${existingPlugin}". Replace it with ${label}?`, + initialValue: false, + }); + + if (clack.isCancel(replace)) { + cancelInteractiveComposioSetup(clack); + } + if (!replace) { + console.log(chalk.dim(`Keeping existing notifiers.${targetName} config.`)); + return false; + } + return true; +} + +async function runInteractiveComposioSlackSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const canReplace = await confirmComposioSlackConflict( + clack, + targetName, + existingPlugin, + opts.force, + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "account" | "channel" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let client: ComposioSetupClient | undefined; + let connectedAccountId: string | undefined; + let channel: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + client = await loadComposioClient(apiKey.apiKey); + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "account"; + continue; + } + + if (step === "account") { + if (!client || !userId) { + step = "api-key"; + continue; + } + const result = await promptInteractiveSlackAccount(clack, client, userId, opts, existing); + if (result === "back") { + step = "user-id"; + continue; + } + connectedAccountId = result; + step = "channel"; + continue; + } + + if (step === "channel") { + const result = await promptInteractiveSlackChannel(clack, opts, existing); + if (result === "back") { + step = "account"; + continue; + } + channel = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, targetName, "Composio Slack", () => + cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "channel"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !connectedAccountId) { + step = "api-key"; + continue; + } + + const resolved: ResolvedComposioSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + targetName, + channel, + connectedAccountId, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioSlackReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "channel") { + step = "channel"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Slack connected account: ${connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template ci-failing`)); + clack.outro("Composio Slack setup complete."); + return "done"; + } +} + +async function runInteractiveComposioDiscordWebhookSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const existingWebhookUrl = + stringValue(opts.webhookUrl) ?? + stringValue(existing["webhookUrl"]) ?? + stringValue(process.env.DISCORD_WEBHOOK_URL); + const explicitConnectedAccountId = stringValue(opts.connectedAccountId); + const existingConnectedAccountId = stringValue(existing["connectedAccountId"]); + const canReplace = await confirmComposioDiscordWebhookConflict( + clack, + targetName, + existingPlugin, + opts.force, + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "webhook" | "account" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let webhookUrl: string | undefined; + let connectedAccountId: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + let setupClient: ComposioSetupClient | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "webhook"; + continue; + } + + if (step === "webhook") { + const result = await promptInteractiveDiscordWebhookUrl( + clack, + webhookUrl ?? existingWebhookUrl, + ); + if (result === "back") { + step = "user-id"; + continue; + } + webhookUrl = result; + connectedAccountId = explicitConnectedAccountId; + step = "account"; + continue; + } + + if (step === "account") { + if (!apiKey || !userId || !webhookUrl) { + step = "api-key"; + continue; + } + + setupClient ??= await loadComposioClient(apiKey.apiKey); + const result = await promptInteractiveDiscordWebhookAccount( + clack, + setupClient, + userId, + webhookUrl, + connectedAccountId ?? explicitConnectedAccountId ?? existingConnectedAccountId, + ); + if (result === "back") { + step = "webhook"; + continue; + } + connectedAccountId = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset( + clack, + rawConfig, + targetName, + "Composio Discord webhook", + () => cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "webhook"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !webhookUrl) { + step = "api-key"; + continue; + } + if (!connectedAccountId) { + step = "account"; + continue; + } + + const resolved: ResolvedDiscordSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + mode: "webhook", + targetName, + webhookUrl, + connectedAccountId, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioDiscordWebhookReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "webhook") { + step = "webhook"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green("✓ Discord webhook configured through Composio")); + console.log(chalk.green(`✓ Discord webhook connected account: ${resolved.connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template basic`)); + clack.outro("Composio Discord webhook setup complete."); + return "done"; + } +} + +async function runInteractiveComposioDiscordBotSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const existingIsBot = + stringValue(existing["defaultApp"]) === "discord" && stringValue(existing["mode"]) === "bot"; + const existingChannelId = + stringValue(opts.channelId) ?? (existingIsBot ? stringValue(existing["channelId"]) : undefined); + const existingConnectedAccountId = existingIsBot + ? (stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"])) + : stringValue(opts.connectedAccountId); + const optionBotToken = stringValue(opts.botToken); + const canReplace = await confirmComposioDiscordWebhookConflict( + clack, + targetName, + existingPlugin, + opts.force, + "Composio Discord bot", + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "channel" | "account" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let client: ComposioSetupClient | undefined; + let channelId: string | undefined; + let connectedAccountId: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + client = await loadComposioClient(apiKey.apiKey); + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "channel"; + continue; + } + + if (step === "channel") { + const result = await promptInteractiveDiscordBotChannel( + clack, + channelId ?? existingChannelId, + ); + if (result === "back") { + step = "user-id"; + continue; + } + if (result !== existingChannelId || (channelId && channelId !== result)) { + connectedAccountId = undefined; + } + channelId = result; + step = "account"; + continue; + } + + if (step === "account") { + if (!client || !userId || !channelId) { + step = "api-key"; + continue; + } + const result = await promptInteractiveDiscordBotAccount( + clack, + client, + userId, + channelId, + connectedAccountId ?? + (channelId === existingChannelId ? existingConnectedAccountId : undefined), + optionBotToken, + ); + if (result === "back") { + step = "channel"; + continue; + } + connectedAccountId = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset( + clack, + rawConfig, + targetName, + "Composio Discord bot", + () => cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "account"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !channelId || !connectedAccountId) { + step = "api-key"; + continue; + } + + const resolved: ResolvedDiscordSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioDiscordBotReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "channel") { + step = "channel"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Discord bot connected account: ${connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template basic`)); + clack.outro("Composio Discord bot setup complete."); + return "done"; + } +} + +async function runInteractiveComposioGmailSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const existingIsGmail = stringValue(existing["defaultApp"]) === "gmail"; + const existingEmailTo = + stringValue(opts.emailTo) ?? (existingIsGmail ? stringValue(existing["emailTo"]) : undefined); + const existingConnectedAccountId = existingIsGmail + ? (stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"])) + : stringValue(opts.connectedAccountId); + const existingAuthConfigId = + stringValue(opts.authConfigId) ?? + (existingIsGmail ? stringValue(existing["authConfigId"]) : undefined); + const canReplace = await confirmComposioDiscordWebhookConflict( + clack, + targetName, + existingPlugin, + opts.force, + "Composio Gmail", + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "email" | "account" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let client: ComposioSetupClient | undefined; + let emailTo: string | undefined; + let connectedAccountId: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + client = await loadComposioClient(apiKey.apiKey); + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "email"; + continue; + } + + if (step === "email") { + const result = await promptInteractiveGmailEmail(clack, emailTo ?? existingEmailTo); + if (result === "back") { + step = "user-id"; + continue; + } + emailTo = result; + step = "account"; + continue; + } + + if (step === "account") { + if (!client || !userId) { + step = "api-key"; + continue; + } + const result = await promptInteractiveGmailAccount( + clack, + client, + userId, + opts, + connectedAccountId ?? existingConnectedAccountId, + existingAuthConfigId, + ); + if (result === "back") { + step = "email"; + continue; + } + connectedAccountId = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, targetName, "Composio Gmail", () => + cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "account"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !emailTo || !connectedAccountId) { + step = "api-key"; + continue; + } + + const resolved: ResolvedMailSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + emailTo, + connectedAccountId, + targetName, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioGmailReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "email") { + step = "email"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioMailConfig(configPath, resolved, targetName); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Gmail connected account: ${connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template basic`)); + clack.outro("Composio Gmail setup complete."); + return "done"; + } +} + +async function showComposioAppPlaceholder( + clack: ClackPrompts, + choice: ComposioAppChoice, +): Promise<"back"> { + printComposioAppRequirements(choice); + const next = await clack.select({ + message: "What do you want to do next?", + options: [ + { value: "back", label: "Back to app choices", hint: "Pick another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return "back"; +} + +async function runInteractiveComposioSetupHub( + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + let directChoice = getDirectComposioAppChoice(opts); + clack.intro("AO Composio notifier setup"); + + while (true) { + const choice = + directChoice ?? + (await clack.select({ + message: "Which Composio app do you want to configure?", + options: [ + { value: "slack", label: "Slack" }, + { + value: "discord-webhook", + label: "Discord webhook", + }, + { + value: "discord-bot", + label: "Discord bot", + }, + { + value: "gmail", + label: "Gmail", + }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + })); + directChoice = undefined; + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + + if (choice === "slack") { + const result = await runInteractiveComposioSlackSetup(clack, opts, configPath, rawConfig); + if (result === "done") return; + continue; + } + + if (choice === "discord-webhook") { + const result = await runInteractiveComposioDiscordWebhookSetup( + clack, + opts, + configPath, + rawConfig, + ); + if (result === "done") return; + continue; + } + + if (choice === "discord-bot") { + const result = await runInteractiveComposioDiscordBotSetup( + clack, + opts, + configPath, + rawConfig, + ); + if (result === "done") return; + continue; + } + + if (choice === "gmail") { + const result = await runInteractiveComposioGmailSetup(clack, opts, configPath, rawConfig); + if (result === "done") return; + continue; + } + + await showComposioAppPlaceholder(clack, choice as ComposioAppChoice); + } +} + +function parseDiscordWebhookUrl(webhookUrl: string): { webhookId: string; webhookToken: string } { + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new ComposioSetupError( + "Invalid Discord webhook URL. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN.", + ); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + const webhookIndex = segments.findIndex((segment) => segment === "webhooks"); + const webhookId = webhookIndex >= 0 ? segments[webhookIndex + 1] : undefined; + const webhookToken = webhookIndex >= 0 ? segments[webhookIndex + 2] : undefined; + if (!webhookId || !webhookToken) { + throw new ComposioSetupError( + "Invalid Discord webhook URL. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN.", + ); + } + + return { + webhookId: decodeURIComponent(webhookId), + webhookToken: decodeURIComponent(webhookToken), + }; +} + +async function createDiscordBearerAuthConfig( + client: ComposioSetupClient, + token: string, + name: string, +): Promise { + if (!client.authConfigs?.create) { + throw new ComposioSetupError( + "Composio SDK client does not expose authConfigs.create(); pass --connected-account-id.", + ); + } + + let authConfig: unknown; + try { + authConfig = await client.authConfigs.create(DISCORD_TOOLKIT, { + type: "use_custom_auth", + name, + authScheme: "BEARER_TOKEN", + credentials: { token }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ComposioSetupError(`Could not create a Composio Discord auth config: ${message}`); + } + + const authConfigId = isRecord(authConfig) ? stringValue(authConfig["id"]) : undefined; + if (!authConfigId) { + throw new ComposioSetupError("Could not create a Composio Discord auth config."); + } + + return authConfigId; +} + +async function createDiscordBearerConnectedAccountWithAuthConfig( + client: ComposioSetupClient, + userId: string, + authConfigId: string, + token: string, +): Promise { + if (!client.connectedAccounts.initiate) { + throw new ComposioSetupError( + "Composio SDK client does not expose connectedAccounts.initiate(); pass --connected-account-id.", + ); + } + + let request: ConnectionRequest; + try { + request = toConnectionRequest( + await client.connectedAccounts.initiate(userId, authConfigId, { + allowMultiple: true, + config: { + authScheme: "BEARER_TOKEN", + val: { + status: "ACTIVE", + token, + }, + }, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ComposioSetupError( + `Could not create a Composio Discord connected account: ${message}`, + ); + } + + if (!request.id) { + throw new ComposioSetupError("Could not create a Composio Discord connected account."); + } + + return request.id; +} + +async function createDiscordBearerConnectedAccount( + client: ComposioSetupClient, + userId: string, + token: string, + name: string, +): Promise { + const authConfigId = await createDiscordBearerAuthConfig(client, token, name); + return createDiscordBearerConnectedAccountWithAuthConfig(client, userId, authConfigId, token); +} + +async function resolveDiscordWebhookConnectedAccountId( + client: ComposioSetupClient, + userId: string, + webhookUrl: string, + connectedAccountId?: string, + explicitConnectedAccountId = false, +): Promise { + if (connectedAccountId) { + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + DISCORD_TOOLKIT, + "Discord webhook", + ); + return account.id; + } catch (error) { + if (explicitConnectedAccountId) throw error; + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + console.log(chalk.dim("Creating a new Discord webhook connected account for this userId.")); + } + } + + const { webhookToken } = parseDiscordWebhookUrl(webhookUrl); + return createDiscordBearerConnectedAccount( + client, + userId, + webhookToken, + "Discord Webhook Auth Config", + ); +} + +async function validateDiscordBotChannelAccess(botToken: string, channelId: string): Promise { + const res = await fetch(`https://discord.com/api/v10/channels/${encodeURIComponent(channelId)}`, { + headers: { + Authorization: `Bot ${botToken}`, + }, + }); + + if (res.ok) return; + + let message = `${res.status} ${res.statusText}`.trim(); + try { + const body = (await res.json()) as unknown; + if (isRecord(body) && stringValue(body["message"])) { + message = `${res.status} ${stringValue(body["message"])}`; + } + } catch { + // Keep the HTTP status message. + } + + if (res.status === 401) { + throw new ComposioSetupError(`Discord bot token is invalid (${message}).`); + } + if (res.status === 403) { + throw new ComposioSetupError( + `Discord bot cannot access channel ${channelId} (${message}). Invite the bot to the server and grant View Channel + Send Messages.`, + ); + } + throw new ComposioSetupError(`Could not validate Discord channel ${channelId}: ${message}.`); +} + +function writeComposioConfig(configPath: string, resolved: ResolvedComposioSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const targetName = resolved.targetName ?? COMPOSIO_NOTIFIER; + const existing = isRecord(notifiers[targetName]) ? notifiers[targetName] : {}; + const channel = channelConfig(resolved.channel); + + const composioConfig: Record = { + ...existing, + plugin: "composio", + defaultApp: "slack", + userId: resolved.userId, + ...channel, + }; + + if ("channelId" in channel) delete composioConfig["channelName"]; + else if ("channelName" in channel) delete composioConfig["channelId"]; + else { + delete composioConfig["channelId"]; + delete composioConfig["channelName"]; + } + + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + + if (resolved.shouldWriteApiKey) { + composioConfig["composioApiKey"] = resolved.apiKey; + } + + delete composioConfig["mode"]; + delete composioConfig["webhookUrl"]; + delete composioConfig["emailTo"]; + delete composioConfig["entityId"]; + delete composioConfig["botToken"]; + delete composioConfig["authConfigId"]; + delete composioConfig["toolVersion"]; + notifiers[targetName] = composioConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, targetName); + } + applyNotifierRoutingPreset(rawConfig, targetName, 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 writeComposioDiscordConfig(configPath: string, resolved: ResolvedDiscordSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingRaw = notifiers[resolved.targetName]; + const existing = isRecord(existingRaw) ? existingRaw : {}; + + const composioConfig: Record = { + ...existing, + plugin: "composio", + defaultApp: "discord", + mode: resolved.mode, + userId: resolved.userId, + toolVersion: DISCORD_TOOL_VERSION, + }; + + if (resolved.mode === "webhook") { + composioConfig["webhookUrl"] = resolved.webhookUrl; + delete composioConfig["channelId"]; + delete composioConfig["channelName"]; + delete composioConfig["emailTo"]; + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + } else { + composioConfig["channelId"] = resolved.channelId; + delete composioConfig["webhookUrl"]; + delete composioConfig["channelName"]; + delete composioConfig["emailTo"]; + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + } + + if (resolved.shouldWriteApiKey) { + composioConfig["composioApiKey"] = resolved.apiKey; + } + + delete composioConfig["entityId"]; + delete composioConfig["botToken"]; + delete composioConfig["authConfigId"]; + notifiers[resolved.targetName] = composioConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, resolved.targetName); + } + applyNotifierRoutingPreset(rawConfig, resolved.targetName, 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 writeComposioMailConfig( + configPath: string, + resolved: ResolvedMailSetup, + targetName = COMPOSIO_MAIL_NOTIFIER, +): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingRaw = notifiers[targetName]; + const existing = isRecord(existingRaw) ? existingRaw : {}; + + const composioConfig: Record = { + ...existing, + plugin: "composio", + defaultApp: "gmail", + userId: resolved.userId, + emailTo: resolved.emailTo, + toolVersion: GMAIL_TOOL_VERSION, + }; + + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + + if (resolved.shouldWriteApiKey) { + composioConfig["composioApiKey"] = resolved.apiKey; + } + + delete composioConfig["entityId"]; + delete composioConfig["channelId"]; + delete composioConfig["channelName"]; + delete composioConfig["webhookUrl"]; + delete composioConfig["mode"]; + delete composioConfig["authConfigId"]; + delete composioConfig["botToken"]; + notifiers[targetName] = composioConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, targetName); + } + applyNotifierRoutingPreset(rawConfig, targetName, 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 printStatus( + resolved: Pick, + accounts: ConnectedAccount[], + targetName = COMPOSIO_NOTIFIER, + rawConfig?: Record, +): void { + console.log(chalk.bold(`AO Composio notifier (${targetName})`)); + console.log(" api key: configured"); + console.log(` userId: ${resolved.userId}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + if (rawConfig) console.log(` routing: ${getNotifierRoutingState(rawConfig, targetName).label}`); + console.log(` active Slack accounts: ${accounts.length}`); + for (const account of accounts) { + console.log(` - ${account.id}${account.alias ? ` (${account.alias})` : ""}`); + } +} + +function printDiscordStatus( + resolved: Pick, + rawConfig?: Record, +): void { + console.log(chalk.bold(`AO Composio Discord notifier (${resolved.targetName})`)); + console.log(" api key: configured"); + console.log(` mode: ${resolved.mode}`); + console.log(` userId: ${resolved.userId}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + if (rawConfig) { + console.log(` routing: ${getNotifierRoutingState(rawConfig, resolved.targetName).label}`); + } +} + +function printMailStatus( + resolved: Pick, + accounts: ConnectedAccount[], + rawConfig?: Record, + targetName = COMPOSIO_MAIL_NOTIFIER, +): void { + console.log(chalk.bold(`AO Composio mail notifier (${targetName})`)); + console.log(" api key: configured"); + console.log(` userId: ${resolved.userId}`); + console.log(` emailTo: ${resolved.emailTo ?? "not configured"}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + if (rawConfig) console.log(` routing: ${getNotifierRoutingState(rawConfig, targetName).label}`); + console.log(` active Gmail accounts: ${accounts.length}`); + for (const account of accounts) { + console.log(` - ${account.id}${account.alias ? ` (${account.alias})` : ""}`); + } +} + +async function resolveSetup( + opts: ComposioSetupOptions, + rawConfig: Record, + nonInteractive: boolean, + targetName = COMPOSIO_NOTIFIER, +): Promise { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const explicitConnectedAccountId = + stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"]); + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + + if (opts.status) { + const accounts = await listActiveSlackAccounts(client, userId); + printStatus( + { apiKey, userId, connectedAccountId: explicitConnectedAccountId }, + accounts, + targetName, + rawConfig, + ); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: explicitConnectedAccountId, + routingPreset, + }; + } + + if (explicitConnectedAccountId) { + const account = await verifyConnectedAccount(client, userId, explicitConnectedAccountId); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: account.id, + routingPreset, + }; + } + + const accounts = await listActiveSlackAccounts(client, userId); + if (accounts.length > 0) { + const account = await chooseAccount(accounts, nonInteractive); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: account.id, + routingPreset, + }; + } + + const connection = await createConnectionRequest(client, userId, parseWaitMs(opts.waitMs)); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: connection.account?.id, + connectionUrl: connection.url, + routingPreset, + }; +} + +export async function runComposioSetupAction(opts: ComposioSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "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) ?? {}; + const existing = getExistingComposioConfig(rawConfig); + const existingPlugin = stringValue(existing["plugin"]); + const directChoice = getDirectComposioAppChoice(opts); + + if (directChoice && nonInteractive) { + throw new ComposioSetupError( + "Composio app flags require interactive setup. Use the dedicated setup command with --non-interactive for scriptable setup.", + ); + } + + if (shouldUseInteractiveComposioHub(opts, nonInteractive)) { + await runInteractiveComposioSetupHub(opts, configPath, rawConfig); + return; + } + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.composio already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveSetup(opts, rawConfig, nonInteractive); + if (opts.status) return; + + if (resolved.connectionUrl && !resolved.connectedAccountId) { + console.log( + chalk.yellow( + "Slack connection did not complete yet. Open the connect URL above, finish the Composio flow, then rerun `ao setup composio`.", + ), + ); + console.log(chalk.dim("No config was changed.")); + return; + } + + writeComposioConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Slack connected account: ${resolved.connectedAccountId}`)); + } + + console.log(chalk.dim("Test it with: ao notify test --to composio --template ci-failing")); +} + +export async function runComposioSlackSetupAction(opts: ComposioSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, nonInteractive)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Slack setup"); + await runInteractiveComposioSlackSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_SLACK_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_SLACK_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_SLACK_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveSetup(opts, rawConfig, nonInteractive, COMPOSIO_SLACK_NOTIFIER); + if (opts.status) return; + + if (resolved.connectionUrl && !resolved.connectedAccountId) { + console.log( + chalk.yellow( + "Slack connection did not complete yet. Open the connect URL above, finish the Composio flow, then rerun `ao setup composio-slack`.", + ), + ); + console.log(chalk.dim("No config was changed.")); + return; + } + + writeComposioConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Slack connected account: ${resolved.connectedAccountId}`)); + } + + console.log( + chalk.dim(`Test it with: ao notify test --to ${COMPOSIO_SLACK_NOTIFIER} --template ci-failing`), + ); +} + +async function resolveDiscordWebhookSetup( + opts: ComposioDiscordWebhookSetupOptions, + rawConfig: Record, +): Promise { + const targetName = COMPOSIO_DISCORD_WEBHOOK_NOTIFIER; + const existing = getExistingNotifierConfig(rawConfig, targetName); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + const explicitConnectedAccountId = stringValue(opts.connectedAccountId); + const existingConnectedAccountId = stringValue(existing["connectedAccountId"]); + const connectedAccountId = explicitConnectedAccountId ?? existingConnectedAccountId; + const webhookUrl = + stringValue(opts.webhookUrl) ?? + stringValue(process.env.DISCORD_WEBHOOK_URL) ?? + stringValue(existing["webhookUrl"]); + + if (opts.status) { + printDiscordStatus({ targetName, mode: "webhook", userId, connectedAccountId }, rawConfig); + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "webhook", + targetName, + webhookUrl, + connectedAccountId, + routingPreset, + }; + } + + if (!webhookUrl) { + throw new ComposioSetupError( + "No Discord webhook URL found. Pass --webhook-url or set DISCORD_WEBHOOK_URL.", + ); + } + parseDiscordWebhookUrl(webhookUrl); + const resolvedConnectedAccountId = await resolveDiscordWebhookConnectedAccountId( + client, + userId, + webhookUrl, + connectedAccountId, + Boolean(explicitConnectedAccountId), + ); + + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "webhook", + targetName, + webhookUrl, + connectedAccountId: resolvedConnectedAccountId, + routingPreset, + }; +} + +async function resolveDiscordBotSetup( + opts: ComposioDiscordBotSetupOptions, + rawConfig: Record, +): Promise { + const targetName = COMPOSIO_DISCORD_BOT_NOTIFIER; + const existing = getExistingNotifierConfig(rawConfig, targetName); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + const connectedAccountId = + stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"]); + const channelId = stringValue(opts.channelId) ?? stringValue(existing["channelId"]); + const botToken = stringValue(opts.botToken) ?? stringValue(process.env.DISCORD_BOT_TOKEN); + + if (opts.status) { + printDiscordStatus({ targetName, mode: "bot", userId, connectedAccountId }, rawConfig); + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId, + routingPreset, + }; + } + + if (!channelId) { + throw new ComposioSetupError("No Discord channel id found. Pass --channel-id."); + } + + if (connectedAccountId) { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + DISCORD_TOOLKIT, + "Discord Bot", + ); + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId: account.id, + routingPreset, + }; + } + + if (!botToken) { + throw new ComposioSetupError( + "No Discord bot token found. Pass --bot-token or set DISCORD_BOT_TOKEN.", + ); + } + + await validateDiscordBotChannelAccess(botToken, channelId); + + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId: await createDiscordBearerConnectedAccount( + client, + userId, + botToken, + "Discord Bot Auth Config", + ), + routingPreset, + }; +} + +async function resolveMailSetup( + opts: ComposioMailSetupOptions, + rawConfig: Record, + nonInteractive: boolean, +): Promise { + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_MAIL_NOTIFIER); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const emailTo = stringValue(opts.emailTo) ?? stringValue(existing["emailTo"]); + const authConfigId = stringValue(opts.authConfigId) ?? stringValue(existing["authConfigId"]); + const optionConnectedAccountId = stringValue(opts.connectedAccountId); + const existingConnectedAccountId = stringValue(existing["connectedAccountId"]); + const connectedAccountId = optionConnectedAccountId ?? existingConnectedAccountId; + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + + if (opts.status) { + const accounts = await listActiveGmailAccounts(client, userId); + printMailStatus({ userId, emailTo, connectedAccountId }, accounts, rawConfig); + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + if (!emailTo) { + throw new ComposioSetupError("No recipient email found. Pass --email-to."); + } + + if (connectedAccountId) { + let account: ConnectedAccount | undefined; + try { + account = await withConnectedAccountDetails( + client, + await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + GMAIL_TOOLKIT, + "Gmail", + () => listActiveGmailAccounts(client, userId), + ), + ); + } catch (err) { + if (optionConnectedAccountId) throw err; + const message = err instanceof Error ? err.message : String(err); + console.log( + chalk.yellow( + `Existing Gmail connected account ${connectedAccountId} could not be used: ${message}. Looking for another Gmail connected account.`, + ), + ); + } + + if (account && (await accountCanSendGmail(client, account))) { + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId: account.id, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + if (account && optionConnectedAccountId) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is missing Gmail send/profile access. Connect Gmail in Composio with send access, then rerun \`ao setup composio-mail --email-to ${emailTo} --connected-account-id ${connectedAccountId}\`, or pass a different Gmail connected account.`, + ); + } + + if (account) { + console.log( + chalk.yellow( + `Existing Gmail connected account ${connectedAccountId} is missing Gmail send/profile access. Looking for another Gmail connected account.`, + ), + ); + } + } + + const accounts = await listUsableGmailAccounts(client, userId); + if (accounts.length > 0) { + const account = await chooseAccount(accounts, nonInteractive, "Gmail"); + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId: account.id, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + if (opts.connect) { + const connection = await createManagedOAuthConnectionRequest( + client, + userId, + GMAIL_TOOLKIT, + "Gmail", + "Gmail Auth Config", + parseWaitMs(opts.waitMs), + { + authConfigId: await resolveGmailConnectAuthConfigId(client, authConfigId, nonInteractive), + }, + ); + + if (connection.account) { + const account = await withConnectedAccountDetails(client, connection.account); + if (!(await accountCanSendGmail(client, account))) { + throw new ComposioSetupError( + `Connected Gmail account ${account.id} is missing Gmail send/profile access. Fix the Gmail connection in Composio, then rerun \`ao setup composio-mail\`.`, + ); + } + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId: account.id, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectionUrl: connection.url, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + throw new ComposioSetupError( + [ + `No active Gmail connected account with send access was found for user ${userId}.`, + "Connect Gmail in Composio first, then rerun `ao setup composio-mail`, or rerun with `--connect` to print a Composio connect URL.", + `You can also pass an existing Gmail account with \`ao setup composio-mail --email-to ${emailTo} --connected-account-id ca_...\`.`, + ].join(" "), + ); +} + +export async function runComposioDiscordWebhookSetupAction( + opts: ComposioDiscordWebhookSetupOptions, +): Promise { + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, opts.nonInteractive || !process.stdin.isTTY)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Discord webhook setup"); + await runInteractiveComposioDiscordWebhookSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_DISCORD_WEBHOOK_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_DISCORD_WEBHOOK_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_DISCORD_WEBHOOK_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveDiscordWebhookSetup(opts, rawConfig); + if (opts.status) return; + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green("✓ Discord webhook configured through Composio")); + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Discord webhook connected account: ${resolved.connectedAccountId}`)); + } + console.log( + chalk.dim( + `Test it with: ao notify test --to ${COMPOSIO_DISCORD_WEBHOOK_NOTIFIER} --template basic`, + ), + ); +} + +export async function runComposioDiscordBotSetupAction( + opts: ComposioDiscordBotSetupOptions, +): Promise { + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, opts.nonInteractive || !process.stdin.isTTY)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Discord bot setup"); + await runInteractiveComposioDiscordBotSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_DISCORD_BOT_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_DISCORD_BOT_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_DISCORD_BOT_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveDiscordBotSetup(opts, rawConfig); + if (opts.status) return; + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Discord bot connected account: ${resolved.connectedAccountId}`)); + console.log( + chalk.dim( + `Test it with: ao notify test --to ${COMPOSIO_DISCORD_BOT_NOTIFIER} --template basic`, + ), + ); +} + +export async function runComposioMailSetupAction(opts: ComposioMailSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, nonInteractive)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Gmail setup"); + await runInteractiveComposioGmailSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_MAIL_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_MAIL_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_MAIL_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveMailSetup(opts, rawConfig, nonInteractive); + if (opts.status) return; + + if (resolved.connectionUrl && !resolved.connectedAccountId) { + console.log( + chalk.yellow( + "Gmail connection did not complete yet. Open the connect URL above, finish the Composio flow, then rerun `ao setup composio-mail`.", + ), + ); + console.log(chalk.dim("No config was changed.")); + return; + } + + writeComposioMailConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Gmail connected account: ${resolved.connectedAccountId}`)); + } + + console.log( + chalk.dim(`Test it with: ao notify test --to ${COMPOSIO_MAIL_NOTIFIER} --template basic`), + ); +} diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index 1d1a4f11e..e02095c44 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -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(); } diff --git a/packages/cli/src/lib/credential-resolver.ts b/packages/cli/src/lib/credential-resolver.ts index 133ab6224..75fb6a6ff 100644 --- a/packages/cli/src/lib/credential-resolver.ts +++ b/packages/cli/src/lib/credential-resolver.ts @@ -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 { } } } - } 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; diff --git a/packages/cli/src/lib/dashboard-setup.ts b/packages/cli/src/lib/dashboard-setup.ts new file mode 100644 index 000000000..49b8de230 --- /dev/null +++ b/packages/cli/src/lib/dashboard-setup.ts @@ -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; +} + +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 { + 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) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingDashboard(rawConfig: Record): Record { + 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) ?? {}; + + 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 { + 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, + rawConfig: Record, +): Promise { + 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, +): 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 { + 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")); + } +} diff --git a/packages/cli/src/lib/desktop-setup.ts b/packages/cli/src/lib/desktop-setup.ts new file mode 100644 index 000000000..7b2d2e922 --- /dev/null +++ b/packages/cli/src/lib/desktop-setup.ts @@ -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; + existingDesktop: Record; +} + +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) ?? {}; + const notifiers = (rawConfig["notifiers"] as Record | undefined) ?? {}; + const existingDesktop = (notifiers["desktop"] as Record | 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 { + 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 | 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 { + 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, + existingDesktop: Record, +): string | undefined { + return ( + stringValue(opts.dashboardUrl) ?? + dashboardUrlFromConfig(rawConfig) ?? + stringValue(existingDesktop["dashboardUrl"]) + ); +} + +async function maybeInstallTerminalNotifier(nonInteractive: boolean): Promise { + 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 { + 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 { + 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 { + 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) ?? {}; + const notifiers = (rawConfig["notifiers"] as Record | undefined) ?? {}; + const existingDesktop = (notifiers["desktop"] as Record | undefined) ?? {}; + + if ( + !conflictAlreadyChecked && + !(await shouldReplaceConflictingDesktop( + existingDesktop["plugin"], + force, + nonInteractive, + )) + ) { + return false; + } + + const desktopConfig: Record = { + ...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 | 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 { + if (!configPath) return false; + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = (rawConfig["notifiers"] as Record | undefined) ?? {}; + const existingDesktop = (notifiers["desktop"] as Record | 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 { + 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")); + } +} diff --git a/packages/cli/src/lib/discord-setup.ts b/packages/cli/src/lib/discord-setup.ts new file mode 100644 index 000000000..840d5f180 --- /dev/null +++ b/packages/cli/src/lib/discord-setup.ts @@ -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; +} + +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 { + 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) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingDiscord(rawConfig: Record): Record { + 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 { + const payload: Record = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + rawConfig: Record, +): Promise { + 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, +): 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) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingDiscord = isRecord(notifiers["discord"]) ? notifiers["discord"] : {}; + const discordConfig: Record = { + ...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 { + 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 { + 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")); + } +} diff --git a/packages/cli/src/lib/notifier-routing.ts b/packages/cli/src/lib/notifier-routing.ts new file mode 100644 index 000000000..b2fc09666 --- /dev/null +++ b/packages/cli/src/lib/notifier-routing.ts @@ -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 = { + "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 { + 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, 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, + 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, 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, + 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(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, + notifierName: string, + notifierLabel: string, + cancel: () => never, +): Promise { + 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"; +} diff --git a/packages/cli/src/lib/notify-test.ts b/packages/cli/src/lib/notify-test.ts new file mode 100644 index 000000000..47b50f05a --- /dev/null +++ b/packages/cli/src/lib/notify-test.ts @@ -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 = { + 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; + 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; + body: string; + json: unknown; +} + +export interface NotifySinkServer { + port: number; + url: string; + requests: NotifySinkRequest[]; + waitForRequest(timeoutMs?: number): Promise; + close(): Promise; +} + +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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function syncNotificationSubject( + data: Record, + sessionId: string, + projectId: string, +): Record { + 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(); + 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 { + 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", target.reference) ?? + registry.get("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 | 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; +} + +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 { + 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 { + 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((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 { + 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 { + 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)); +} diff --git a/packages/cli/src/lib/openclaw-setup.ts b/packages/cli/src/lib/openclaw-setup.ts new file mode 100644 index 000000000..700fc8c7a --- /dev/null +++ b/packages/cli/src/lib/openclaw-setup.ts @@ -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; +} + +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 { + 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) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingOpenClaw(rawConfig: Record): Record { + 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; + 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; + 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 { + return ( + stringValue(opts.openclawConfigPath) ?? + stringValue(existingOpenClaw["openclawConfigPath"]) ?? + stringValue(existingOpenClaw["configPath"]) ?? + getOpenClawJsonPath() + ); +} + +function resolveConfiguredToken( + existingOpenClaw: Record, + 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 { + 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": "", + "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 { + 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 { + 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 { + 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, + initialOpenClawConfigPath: string, +): Promise { + 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, +): Promise { + 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, + rawConfig: Record, +): Promise { + 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, + _rawConfig: Record, +): Promise { + 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) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const openclawConfig: Record = { + 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 { + 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 { + 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 { + 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")); + } +} diff --git a/packages/cli/src/lib/project-supervisor.ts b/packages/cli/src/lib/project-supervisor.ts index fdfa70a13..2a7747a7e 100644 --- a/packages/cli/src/lib/project-supervisor.ts +++ b/packages/cli/src/lib/project-supervisor.ts @@ -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 ` / + * `ao start ` 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 { - 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 { 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 => { + const run = async (runOptions: { swallowErrors?: boolean } = {}): Promise => { 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); diff --git a/packages/cli/src/lib/resolve-project.ts b/packages/cli/src/lib/resolve-project.ts index b6bb2bc69..8d1c34de2 100644 --- a/packages/cli/src/lib/resolve-project.ts +++ b/packages/cli/src/lib/resolve-project.ts @@ -27,6 +27,7 @@ import { isRepoUrl, loadConfig, parseRepoUrl, + recordActivityEvent, registerProjectInGlobalConfig, resolveCloneTarget, isRepoAlreadyCloned, @@ -196,6 +197,18 @@ async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise 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 { 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; } diff --git a/packages/cli/src/lib/shutdown.ts b/packages/cli/src/lib/shutdown.ts index 228f5f88d..8e024e4f8 100644 --- a/packages/cli/src/lib/shutdown.ts +++ b/packages/cli/src/lib/shutdown.ts @@ -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 diff --git a/packages/cli/src/lib/slack-setup.ts b/packages/cli/src/lib/slack-setup.ts new file mode 100644 index 000000000..a86e0c08c --- /dev/null +++ b/packages/cli/src/lib/slack-setup.ts @@ -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; +} + +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 { + 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) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingSlack(rawConfig: Record): Record { + 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 { + const payload: Record = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + rawConfig: Record, +): Promise { + 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, +): 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) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingSlack = isRecord(notifiers["slack"]) ? notifiers["slack"] : {}; + const slackConfig: Record = { + ...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 { + 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 { + 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")); + } +} diff --git a/packages/cli/src/lib/webhook-setup.ts b/packages/cli/src/lib/webhook-setup.ts new file mode 100644 index 000000000..a292ad547 --- /dev/null +++ b/packages/cli/src/lib/webhook-setup.ts @@ -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; + retries: number; + retryDelayMs: number; +} + +interface ConfigContext { + configPath: string; + rawConfig: Record; +} + +interface ResolvedWebhookSetup { + url: string; + headers?: Record; + 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 { + 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) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingWebhook(rawConfig: Record): Record { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + rawConfig: Record, +): Promise { + 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, +): 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) ?? {}; + + 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 { + 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 { + 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")); + } +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 71cb76136..108118d5a 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -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); diff --git a/packages/core/__tests__/config.test.ts b/packages/core/__tests__/config.test.ts index 1bd68af56..041aac7ef 100644 --- a/packages/core/__tests__/config.test.ts +++ b/packages/core/__tests__/config.test.ts @@ -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({ diff --git a/packages/core/package.json b/packages/core/package.json index 0b83ed14b..3ac2696d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" }, diff --git a/packages/core/src/__tests__/activity-events-agent-report.test.ts b/packages/core/src/__tests__/activity-events-agent-report.test.ts new file mode 100644 index 000000000..88c56d71a --- /dev/null +++ b/packages/core/src/__tests__/activity-events-agent-report.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/__tests__/activity-events-config.test.ts b/packages/core/src/__tests__/activity-events-config.test.ts new file mode 100644 index 000000000..29db59c50 --- /dev/null +++ b/packages/core/src/__tests__/activity-events-config.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/__tests__/activity-events-migration.test.ts b/packages/core/src/__tests__/activity-events-migration.test.ts new file mode 100644 index 000000000..efaf60a94 --- /dev/null +++ b/packages/core/src/__tests__/activity-events-migration.test.ts @@ -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), + 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; + 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; + 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(); + }); +}); diff --git a/packages/core/src/__tests__/activity-events-plugin-registry.test.ts b/packages/core/src/__tests__/activity-events-plugin-registry.test.ts new file mode 100644 index 000000000..ab5a3187a --- /dev/null +++ b/packages/core/src/__tests__/activity-events-plugin-registry.test.ts @@ -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 { + 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 | 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([]); + }); +}); diff --git a/packages/core/src/__tests__/activity-log.test.ts b/packages/core/src/__tests__/activity-log.test.ts index 858cfea74..6d8288b8d 100644 --- a/packages/core/src/__tests__/activity-log.test.ts +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/agent-report.test.ts b/packages/core/src/__tests__/agent-report.test.ts index 7e3fddce1..f49483b9d 100644 --- a/packages/core/src/__tests__/agent-report.test.ts +++ b/packages/core/src/__tests__/agent-report.test.ts @@ -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 diff --git a/packages/core/src/__tests__/code-review-manager.test.ts b/packages/core/src/__tests__/code-review-manager.test.ts new file mode 100644 index 000000000..8c9e4802a --- /dev/null +++ b/packages/core/src/__tests__/code-review-manager.test.ts @@ -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 & { 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 { + 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((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((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, + }, + ]); + }); +}); diff --git a/packages/core/src/__tests__/code-review-store.test.ts b/packages/core/src/__tests__/code-review-store.test.ts new file mode 100644 index 000000000..2f7e7c422 --- /dev/null +++ b/packages/core/src/__tests__/code-review-store.test.ts @@ -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/); + }); +}); diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 1a6ce9c49..8dd359161 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/__tests__/dashboard-notifications.test.ts b/packages/core/src/__tests__/dashboard-notifications.test.ts new file mode 100644 index 000000000..bea947638 --- /dev/null +++ b/packages/core/src/__tests__/dashboard-notifications.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/core/src/__tests__/events-db.test.ts b/packages/core/src/__tests__/events-db.test.ts new file mode 100644 index 000000000..f1f21237a --- /dev/null +++ b/packages/core/src/__tests__/events-db.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/__tests__/global-config.test.ts b/packages/core/src/__tests__/global-config.test.ts index 25f4a755a..2e7c51628 100644 --- a/packages/core/src/__tests__/global-config.test.ts +++ b/packages/core/src/__tests__/global-config.test.ts @@ -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"); diff --git a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts index 70b0b0296..d1d844899 100644 --- a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts @@ -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"; diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 13b8ef32a..e78146fa1 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -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 } | 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) + : 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 | undefined; const data = event?.data as Record | undefined; - return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete"; + const reaction = + data?.reaction && typeof data.reaction === "object" + ? (data.reaction as Record) + : 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, }), }), ); diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 03ac1c16b..47c66b0e0 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -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(); + return { + ...actual, + renameSync: vi.fn((...args: Parameters) => + 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)["contentSample"] as string; + expect(sample.length).toBe(200); + expect((call![0].data as Record)["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", () => { diff --git a/packages/core/src/__tests__/migration-storage-v2.test.ts b/packages/core/src/__tests__/migration-storage-v2.test.ts index 3d7cc4b09..915431ce9 100644 --- a/packages/core/src/__tests__/migration-storage-v2.test.ts +++ b/packages/core/src/__tests__/migration-storage-v2.test.ts @@ -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(), diff --git a/packages/core/src/__tests__/notification-data.test.ts b/packages/core/src/__tests__/notification-data.test.ts new file mode 100644 index 000000000..1358d9d19 --- /dev/null +++ b/packages/core/src/__tests__/notification-data.test.ts @@ -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", + ); + }); +}); diff --git a/packages/core/src/__tests__/observability.test.ts b/packages/core/src/__tests__/observability.test.ts index 3b034eb5a..78ca4fed3 100644 --- a/packages/core/src/__tests__/observability.test.ts +++ b/packages/core/src/__tests__/observability.test.ts @@ -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"); diff --git a/packages/core/src/__tests__/plugin-integration.test.ts b/packages/core/src/__tests__/plugin-integration.test.ts index b8e736c9c..04e38c852 100644 --- a/packages/core/src/__tests__/plugin-integration.test.ts +++ b/packages/core/src/__tests__/plugin-integration.test.ts @@ -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() }; }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 690a7bbeb..a66bf3744 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -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 }>("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, diff --git a/packages/core/src/__tests__/recovery-manager.test.ts b/packages/core/src/__tests__/recovery-manager.test.ts new file mode 100644 index 000000000..80647dc29 --- /dev/null +++ b/packages/core/src/__tests__/recovery-manager.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/__tests__/session-manager-instrumentation.test.ts b/packages/core/src/__tests__/session-manager-instrumentation.test.ts new file mode 100644 index 000000000..69b82e44c --- /dev/null +++ b/packages/core/src/__tests__/session-manager-instrumentation.test.ts @@ -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 = { + 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[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)["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((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)["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)["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)["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; + expect(data["renameSucceeded"]).toBe(true); + expect(data["renamedTo"]).toMatch(/\.corrupt-\d+$/); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index e8acb988d..17300fca6 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -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(); }); diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index 9445d16f3..3ba13d03c 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -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).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).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).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 }); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 8f2057e27..d92c1a4f1 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -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((resolve) => { + releaseWorkspace = resolve; + }); + (mockWorkspace.create as ReturnType).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((resolve) => { + releaseWorkspace = resolve; + }); + const ensureWorkspacePath = join(tmpDir, "ws-ensure"); + (mockWorkspace.create as ReturnType).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"); + }); + }); }); diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index e7a51e9df..b20f05ffa 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -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([]), diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts index b370ba619..e925ba31f 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/__tests__/utils.test.ts @@ -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", () => { diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index 6065c3d41..323862caf 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -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, 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; } } diff --git a/packages/core/src/activity-log.ts b/packages/core/src/activity-log.ts index 1334bfb2e..56a1e02c3 100644 --- a/packages/core/src/activity-log.ts +++ b/packages/core/src/activity-log.ts @@ -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 diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts index 07e380e30..091f56a3d 100644 --- a/packages/core/src/agent-report.ts +++ b/packages/core/src/agent-report.ts @@ -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}`); } diff --git a/packages/core/src/code-review-manager.ts b/packages/core/src/code-review-manager.ts new file mode 100644 index 000000000..756cc3d1c --- /dev/null +++ b/packages/core/src/code-review-manager.ts @@ -0,0 +1,1050 @@ +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { + type CodeReviewFinding, + type CodeReviewSeverity, + createCodeReviewStore, + type CodeReviewRun, + type CodeReviewRunStatus, + type CodeReviewRunSummary, + type CodeReviewStore, +} from "./code-review-store.js"; +import { getProjectCodeReviewsDir } from "./paths.js"; +import { + isOrchestratorSession, + SessionNotFoundError, + type OrchestratorConfig, + type ProjectConfig, + type Session, + type SessionManager, +} from "./types.js"; +import { getShell, isWindows, killProcessTree } from "./platform.js"; + +const REVIEW_COMMAND_TIMEOUT_MS = 10 * 60_000; +const REVIEW_COMMAND_MAX_BUFFER = 8 * 1024 * 1024; +const REVIEW_RUN_CREATION_LOCK_FILE = ".create-run.lock"; +const REVIEW_RUN_EXECUTION_LOCK_PREFIX = ".execute-run-"; +const REVIEW_RUN_CREATION_LOCK_WAIT_MS = 5_000; +const REVIEW_RUN_CREATION_LOCK_STALE_MS = 30_000; +const REVIEW_LOCK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +async function execFileAsync( + file: string, + args: string[], + options: { + cwd?: string; + timeout?: number; + maxBuffer?: number; + env?: NodeJS.ProcessEnv; + shell?: boolean | string; + windowsHide?: boolean; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + const { execFile } = await import("node:child_process"); + return promisify(execFile)(file, args, { windowsHide: true, ...options }); +} + +async function execFileWithClosedStdin( + file: string, + args: string[], + options: { + cwd?: string; + timeout?: number; + maxBuffer?: number; + env?: NodeJS.ProcessEnv; + shell?: boolean | string; + windowsHide?: boolean; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + const { spawn } = await import("node:child_process"); + + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options.cwd, + env: options.env, + shell: options.shell, + windowsHide: options.windowsHide ?? true, + detached: !isWindows(), + stdio: ["ignore", "pipe", "pipe"], + }); + const maxBuffer = options.maxBuffer ?? REVIEW_COMMAND_MAX_BUFFER; + let stdout = ""; + let stderr = ""; + let settled = false; + + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + callback(); + }; + + const terminateChild = () => { + if (child.pid !== undefined) { + return killProcessTree(child.pid).catch(() => undefined); + } + child.kill("SIGTERM"); + return Promise.resolve(); + }; + + const fail = (message: string, code?: number | null, signal?: NodeJS.Signals | null) => { + const error = new Error(message) as Error & { + code?: number | null; + signal?: NodeJS.Signals | null; + stdout?: string; + stderr?: string; + }; + error.code = code; + error.signal = signal; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }; + + const timer = + options.timeout && options.timeout > 0 + ? setTimeout(() => { + void terminateChild().finally(() => { + finish(() => fail(`Command timed out after ${options.timeout}ms`, null, "SIGTERM")); + }); + }, options.timeout) + : null; + + const append = (kind: "stdout" | "stderr", chunk: Buffer) => { + const next = chunk.toString(); + if (kind === "stdout") stdout += next; + else stderr += next; + + if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) <= maxBuffer) return; + void terminateChild(); + finish(() => fail(`Command output exceeded maxBuffer ${maxBuffer}`)); + }; + + child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk)); + child.once("error", (error) => finish(() => reject(error))); + child.once("close", (code, signal) => { + finish(() => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + fail(`Command failed with code ${code ?? signal ?? "unknown"}`, code, signal); + }); + }); + }); +} + +export type CodeReviewRequestSource = "cli" | "web" | "system"; + +export interface TriggerCodeReviewInput { + sessionId: string; + requestedBy?: CodeReviewRequestSource; + status?: CodeReviewRunStatus; + summary?: string; +} + +export interface TriggerCodeReviewOptions { + config: OrchestratorConfig; + sessionManager: SessionManager; + storeFactory?: (projectId: string) => CodeReviewStore; + resolveTargetSha?: (session: Session) => Promise; + now?: Date; +} + +export interface CodeReviewRunnerFinding { + severity?: CodeReviewSeverity; + title?: string; + body?: string; + filePath?: string; + startLine?: number; + endLine?: number; + category?: string; + confidence?: number; + fingerprint?: string; +} + +export interface CodeReviewRunnerResult { + findings?: CodeReviewRunnerFinding[]; + summary?: string; + rawOutput?: string; +} + +export interface CodeReviewRunnerContext { + config: OrchestratorConfig; + project: ProjectConfig; + session: Session; + run: CodeReviewRun; + workspacePath: string; + baseRef: string; +} + +export type CodeReviewRunner = ( + context: CodeReviewRunnerContext, +) => Promise; + +export type PrepareCodeReviewWorkspace = (context: { + projectId: string; + project: ProjectConfig; + session: Session; + run: CodeReviewRun; +}) => Promise; + +export interface ExecuteCodeReviewRunOptions { + config: OrchestratorConfig; + sessionManager: SessionManager; + storeFactory?: (projectId: string) => CodeReviewStore; + prepareWorkspace?: PrepareCodeReviewWorkspace; + runReviewer?: CodeReviewRunner; + now?: () => Date; + force?: boolean; +} + +export interface ExecuteCodeReviewRunInput { + projectId: string; + runId: string; +} + +export interface SendCodeReviewFindingsOptions { + config: OrchestratorConfig; + sessionManager: SessionManager; + storeFactory?: (projectId: string) => CodeReviewStore; + now?: () => Date; +} + +export interface SendCodeReviewFindingsInput { + projectId: string; + runId: string; +} + +export interface SendCodeReviewFindingsResult { + run: CodeReviewRunSummary; + sentFindingCount: number; + message: string; +} + +export interface MarkOutdatedCodeReviewRunsInput { + store: CodeReviewStore; + session: Session; + resolveTargetSha?: (session: Session) => Promise; + now?: Date; +} + +export class CodeReviewRunNotFoundError extends Error { + constructor(runId: string) { + super(`Code review run not found: ${runId}`); + this.name = "CodeReviewRunNotFoundError"; + } +} + +export class CodeReviewRunNotExecutableError extends Error { + readonly runId: string; + readonly reviewerSessionId: string; + readonly status: CodeReviewRunStatus; + + constructor(run: CodeReviewRun) { + super(`Code review run ${run.reviewerSessionId} is ${run.status}, not queued`); + this.name = "CodeReviewRunNotExecutableError"; + this.runId = run.id; + this.reviewerSessionId = run.reviewerSessionId; + this.status = run.status; + } +} + +export class CodeReviewInvalidSessionError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeReviewInvalidSessionError"; + } +} + +export class CodeReviewNoOpenFindingsError extends Error { + readonly runId: string; + readonly reviewerSessionId: string; + + constructor(run: CodeReviewRun) { + super(`No open review findings to send for ${run.reviewerSessionId}.`); + this.name = "CodeReviewNoOpenFindingsError"; + this.runId = run.id; + this.reviewerSessionId = run.reviewerSessionId; + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parsePrNumber(url: string | undefined): number | undefined { + if (!url) return undefined; + const match = url.match(/\/pull\/(\d+)(?:\D*$|$)/); + if (!match) return undefined; + const parsed = Number.parseInt(match[1], 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +function formatFindingLocation(finding: CodeReviewFinding): string | null { + if (!finding.filePath) return null; + if (finding.startLine === undefined) return finding.filePath; + if (finding.endLine !== undefined && finding.endLine !== finding.startLine) { + return `${finding.filePath}:${finding.startLine}-${finding.endLine}`; + } + return `${finding.filePath}:${finding.startLine}`; +} + +function formatFindingForAgent(finding: CodeReviewFinding, index: number): string { + const lines = [`${index}. [${finding.severity}] ${finding.title}`]; + const location = formatFindingLocation(finding); + if (location) lines.push(` Location: ${location}`); + if (finding.confidence !== undefined) lines.push(` Confidence: ${finding.confidence}`); + lines.push(" Details:"); + lines.push( + ...finding.body + .split(/\r?\n/) + .map((line) => ` ${line}`) + .filter((line, lineIndex, allLines) => line.trim() || lineIndex < allLines.length - 1), + ); + return lines.join("\n"); +} + +function parseFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function parseSeverity(value: unknown): CodeReviewSeverity { + switch (value) { + case "error": + case "warning": + case "info": + return value; + default: + return "warning"; + } +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stripMarkdownJsonFence(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + return match?.[1]?.trim() ?? trimmed; +} + +function tryParseJsonCandidate(value: string): unknown | null { + const candidates = [stripMarkdownJsonFence(value)]; + const fenced = value.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + + for (const line of value + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean) + .reverse()) { + if (line.startsWith("{") || line.startsWith("[")) { + candidates.push(line); + } + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate) as unknown; + } catch { + // Keep trying looser candidates. + } + } + + return null; +} + +function normalizeFinding(value: unknown, fallbackIndex: number): CodeReviewRunnerFinding | null { + const record = asRecord(value); + if (!record) return null; + + const body = + typeof record["body"] === "string" + ? record["body"].trim() + : typeof record["message"] === "string" + ? record["message"].trim() + : ""; + if (!body) return null; + + const title = + typeof record["title"] === "string" && record["title"].trim() + ? record["title"].trim() + : `Review finding ${fallbackIndex}`; + + const filePath = + typeof record["filePath"] === "string" + ? record["filePath"] + : typeof record["path"] === "string" + ? record["path"] + : undefined; + + return { + severity: parseSeverity(record["severity"]), + title: truncate(title, 160), + body: truncate(body, 12_000), + filePath, + startLine: parseFiniteNumber(record["startLine"] ?? record["line"]), + endLine: parseFiniteNumber(record["endLine"]), + category: typeof record["category"] === "string" ? record["category"] : undefined, + confidence: parseFiniteNumber(record["confidence"]), + fingerprint: typeof record["fingerprint"] === "string" ? record["fingerprint"] : undefined, + }; +} + +export function parseReviewerOutput(output: string): CodeReviewRunnerFinding[] { + const trimmed = output.trim(); + if (!trimmed) return []; + + const parsed = tryParseJsonCandidate(trimmed); + const parsedRecord = asRecord(parsed); + const rawFindings = Array.isArray(parsed) + ? parsed + : Array.isArray(parsedRecord?.["findings"]) + ? parsedRecord["findings"] + : null; + + if (rawFindings) { + return rawFindings + .map((finding, index) => normalizeFinding(finding, index + 1)) + .filter((finding): finding is CodeReviewRunnerFinding => finding !== null); + } + + if (/^no findings?\.?$/i.test(trimmed)) return []; + + return [ + { + severity: "warning", + title: "Reviewer output", + body: truncate(trimmed, 12_000), + }, + ]; +} + +function allocateReviewerSessionId(existingRuns: CodeReviewRun[], sessionPrefix: string): string { + let max = 0; + const pattern = new RegExp(`^${escapeRegex(sessionPrefix)}-rev-(\\d+)$`); + + for (const run of existingRuns) { + const match = run.reviewerSessionId.match(pattern); + if (!match) continue; + const parsed = Number.parseInt(match[1], 10); + if (!Number.isNaN(parsed) && parsed > max) { + max = parsed; + } + } + + return `${sessionPrefix}-rev-${max + 1}`; +} + +function isFsErrorWithCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function assertSafeReviewLockId(id: string, label: string): void { + if (!id || id === "." || id === ".." || !REVIEW_LOCK_ID_PATTERN.test(id)) { + throw new Error(`Unsafe ${label}: "${id}"`); + } +} + +async function acquireCodeReviewStoreLock({ + store, + lockFileName, + label, +}: { + store: CodeReviewStore; + lockFileName: string; + label: string; +}): Promise<() => void> { + mkdirSync(store.storeDir, { recursive: true }); + const lockPath = join(store.storeDir, lockFileName); + const startedAt = Date.now(); + + while (true) { + try { + const fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${process.pid}\n${new Date().toISOString()}\n`); + let released = false; + return () => { + if (released) return; + released = true; + try { + closeSync(fd); + } catch { + // The descriptor may already be closed if process cleanup raced with release. + } + try { + unlinkSync(lockPath); + } catch { + // Another process may already have cleaned up a stale lock. + } + }; + } catch (error) { + if (!isFsErrorWithCode(error, "EEXIST")) { + throw error; + } + + try { + const lockAgeMs = Date.now() - statSync(lockPath).mtimeMs; + if (lockAgeMs > REVIEW_RUN_CREATION_LOCK_STALE_MS) { + unlinkSync(lockPath); + continue; + } + } catch (staleError) { + if (isFsErrorWithCode(staleError, "ENOENT")) { + continue; + } + } + + if (Date.now() - startedAt > REVIEW_RUN_CREATION_LOCK_WAIT_MS) { + throw new Error(`Timed out waiting for ${label} lock`, { cause: error }); + } + await delay(25); + } + } +} + +async function withReviewRunCreationLock( + store: CodeReviewStore, + callback: () => T | Promise, +): Promise { + const release = await acquireCodeReviewStoreLock({ + store, + lockFileName: REVIEW_RUN_CREATION_LOCK_FILE, + label: "code review run creation", + }); + try { + return await callback(); + } finally { + release(); + } +} + +async function withReviewRunExecutionLock( + store: CodeReviewStore, + runId: string, + callback: () => T | Promise, +): Promise { + assertSafeReviewLockId(runId, "review run id"); + const release = await acquireCodeReviewStoreLock({ + store, + lockFileName: `${REVIEW_RUN_EXECUTION_LOCK_PREFIX}${runId}.lock`, + label: `code review run ${runId} execution`, + }); + try { + return await callback(); + } finally { + release(); + } +} + +const SUPERSEDABLE_RUN_STATUSES: ReadonlySet = new Set([ + "queued", + "needs_triage", + "sent_to_agent", + "waiting_update", + "clean", +]); + +function markSupersededReviewRuns({ + store, + existingRuns, + linkedSessionId, + targetSha, + now, +}: { + store: CodeReviewStore; + existingRuns: CodeReviewRun[]; + linkedSessionId: string; + targetSha: string | undefined; + now: Date; +}): number { + if (!targetSha) return 0; + + let updatedCount = 0; + + for (const run of existingRuns) { + if (run.linkedSessionId !== linkedSessionId) continue; + if (!run.targetSha || run.targetSha === targetSha) continue; + if (!SUPERSEDABLE_RUN_STATUSES.has(run.status)) continue; + store.updateRun(run.id, { status: "outdated" }, now); + updatedCount++; + } + + return updatedCount; +} + +async function resolveGitHeadSha(session: Session): Promise { + const cwd = session.workspacePath; + if (!cwd) return undefined; + + try { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd, + timeout: 5_000, + }); + const sha = stdout.trim(); + return sha.length > 0 ? sha : undefined; + } catch { + return undefined; + } +} + +async function git(cwd: string, args: string[], timeout = 30_000): Promise { + const { stdout } = await execFileAsync("git", args, { cwd, timeout }); + return stdout.trim(); +} + +async function resolveWorkspaceHead( + workspacePath: string | null | undefined, +): Promise { + if (!workspacePath) return undefined; + try { + return await git(workspacePath, ["rev-parse", "HEAD"], 5_000); + } catch { + return undefined; + } +} + +async function removeReviewerWorktree(repoPath: string, workspacePath: string): Promise { + if (!existsSync(workspacePath)) { + try { + await git(repoPath, ["worktree", "prune"]); + } catch { + // Best-effort cleanup of stale git metadata before adding the worktree again. + } + return; + } + + try { + await git(repoPath, ["worktree", "remove", "--force", workspacePath]); + return; + } catch { + try { + await git(repoPath, ["worktree", "prune"]); + } catch { + // Best-effort before falling back to directory removal. + } + rmSync(workspacePath, { recursive: true, force: true }); + } +} + +export async function markOutdatedCodeReviewRunsForSession({ + store, + session, + resolveTargetSha = resolveGitHeadSha, + now = new Date(), +}: MarkOutdatedCodeReviewRunsInput): Promise { + const targetSha = await resolveTargetSha(session); + return markSupersededReviewRuns({ + store, + existingRuns: store.listRuns({ linkedSessionId: session.id }), + linkedSessionId: session.id, + targetSha, + now, + }); +} + +export async function prepareGitReviewerWorkspace({ + projectId, + project, + session, + run, +}: { + projectId: string; + project: ProjectConfig; + session: Session; + run: CodeReviewRun; +}): Promise { + const workspaceRoot = join(getProjectCodeReviewsDir(projectId), "workspaces"); + const workspacePath = join(workspaceRoot, run.reviewerSessionId); + mkdirSync(workspaceRoot, { recursive: true }); + await removeReviewerWorktree(project.path, workspacePath); + + const ref = run.targetSha ?? (await resolveWorkspaceHead(session.workspacePath)) ?? "HEAD"; + await git(project.path, ["worktree", "add", "--detach", workspacePath, ref], 60_000); + return workspacePath; +} + +function buildDefaultReviewPrompt(context: CodeReviewRunnerContext): string { + return [ + "You are an AO reviewer agent. Review this repository snapshot for concrete bugs only.", + "Do not modify files. Do not publish comments anywhere.", + `Review the changes against base ref "${context.baseRef}". Start with: git diff --merge-base ${context.baseRef} HEAD -- .`, + "If that diff command fails, inspect git status/log and compare this detached reviewer workspace to the base ref using read-only commands.", + `Linked coding worker: ${context.session.id}`, + `Reviewer run: ${context.run.reviewerSessionId}`, + `Base ref: ${context.baseRef}`, + "Return only JSON using this schema:", + '{"findings":[{"severity":"warning|error|info","title":"short title","body":"specific issue and fix","filePath":"optional/path","startLine":1,"endLine":1,"confidence":0.8}]}', + 'If there are no concrete bugs, return {"findings":[]}.', + ].join("\n"); +} + +async function readOutputFile(path: string): Promise { + if (!existsSync(path)) return null; + try { + return readFileSync(path, "utf-8"); + } catch { + return null; + } +} + +export function createShellCodeReviewRunner(command: string): CodeReviewRunner { + return async (context) => { + const shell = getShell(); + const { stdout, stderr } = await execFileAsync(shell.cmd, shell.args(command), { + cwd: context.workspacePath, + timeout: REVIEW_COMMAND_TIMEOUT_MS, + maxBuffer: REVIEW_COMMAND_MAX_BUFFER, + env: process.env, + }); + return { rawOutput: stdout.trim() || stderr.trim() }; + }; +} + +export function buildCodexCodeReviewArgs(outputFile: string, prompt: string): string[] { + return ["exec", "--sandbox", "read-only", "--output-last-message", outputFile, prompt]; +} + +export async function runCodexCodeReview( + context: CodeReviewRunnerContext, +): Promise { + const outputFile = join(context.workspacePath, ".ao-code-review-output.json"); + const prompt = buildDefaultReviewPrompt(context); + const args = buildCodexCodeReviewArgs(outputFile, prompt); + + try { + const { stdout, stderr } = await execFileWithClosedStdin("codex", args, { + cwd: context.workspacePath, + timeout: REVIEW_COMMAND_TIMEOUT_MS, + maxBuffer: REVIEW_COMMAND_MAX_BUFFER, + env: process.env, + shell: isWindows(), + }); + const outputFileContents = await readOutputFile(outputFile); + const rawOutput = outputFileContents ?? (stdout.trim() || stderr.trim()); + return { rawOutput }; + } catch (error) { + const details = + error instanceof Error && "stderr" in error && typeof error.stderr === "string" + ? error.stderr.trim() + : error instanceof Error + ? error.message + : String(error); + throw new Error(`Codex review failed: ${details}`, { cause: error }); + } +} + +function defaultReviewSummary(session: Session, source: CodeReviewRequestSource): string { + const sourceLabel = source === "cli" ? "CLI" : source === "web" ? "dashboard" : "automation"; + return `Review requested from ${sourceLabel} for ${session.id}.`; +} + +export function formatCodeReviewFindingsForAgent({ + run, + findings, + session, +}: { + run: CodeReviewRun; + findings: CodeReviewFinding[]; + session: Session; +}): string { + const prLabel = run.prNumber + ? `PR #${run.prNumber}${run.prUrl ? ` (${run.prUrl})` : ""}` + : run.prUrl + ? `PR ${run.prUrl}` + : "the current PR"; + const targetLabel = run.targetSha ? `\nTarget SHA reviewed: ${run.targetSha}` : ""; + + return [ + `AO reviewer ${run.reviewerSessionId} found ${findings.length} open issue${ + findings.length === 1 ? "" : "s" + } for ${prLabel}.`, + `Linked coding worker: ${session.id}`, + `Review run: ${run.id}${targetLabel}`, + "", + "Please address each finding below. Verify each issue against the current source before editing, then update the PR branch and push your fixes.", + "When you start working on these, report `ao report addressing-reviews`. When the fixes are ready for another review, report `ao report ready-for-review`.", + "", + "Findings:", + findings.map((finding, index) => formatFindingForAgent(finding, index + 1)).join("\n\n"), + ].join("\n"); +} + +export async function triggerCodeReviewForSession( + { + config, + sessionManager, + storeFactory = createCodeReviewStore, + resolveTargetSha = resolveGitHeadSha, + now = new Date(), + }: TriggerCodeReviewOptions, + input: TriggerCodeReviewInput, +): Promise { + const session = await sessionManager.get(input.sessionId); + if (!session) { + throw new SessionNotFoundError(input.sessionId); + } + + const project = config.projects[session.projectId]; + if (!project) { + throw new CodeReviewInvalidSessionError( + `Unknown project for session ${session.id}: ${session.projectId}`, + ); + } + + const sessionPrefix = project.sessionPrefix ?? session.projectId; + const allSessionPrefixes = Object.entries(config.projects).map( + ([projectId, projectConfig]) => projectConfig.sessionPrefix ?? projectId, + ); + if (isOrchestratorSession(session, sessionPrefix, allSessionPrefixes)) { + throw new CodeReviewInvalidSessionError( + `Cannot request code review for orchestrator session: ${session.id}`, + ); + } + + const store = storeFactory(session.projectId); + const prUrl = session.pr?.url ?? session.metadata["pr"]; + const prNumber = session.pr?.number ?? parsePrNumber(prUrl); + const targetSha = await resolveTargetSha(session); + const requestedBy = input.requestedBy ?? "system"; + + return withReviewRunCreationLock(store, () => { + const existingRuns = store.listRuns(); + const reviewerSessionId = allocateReviewerSessionId(existingRuns, sessionPrefix); + + markSupersededReviewRuns({ + store, + existingRuns, + linkedSessionId: session.id, + targetSha, + now, + }); + + const run = store.createRun( + { + linkedSessionId: session.id, + reviewerSessionId, + status: input.status ?? "queued", + targetSha, + prNumber, + prUrl, + summary: input.summary ?? defaultReviewSummary(session, requestedBy), + }, + now, + ); + + return { + ...run, + findingCount: 0, + openFindingCount: 0, + dismissedFindingCount: 0, + sentFindingCount: 0, + resolvedFindingCount: 0, + }; + }); +} + +function summarizeRun(store: CodeReviewStore, runId: string): CodeReviewRunSummary { + const run = store.listRunSummaries().find((entry) => entry.id === runId); + if (!run) { + throw new CodeReviewRunNotFoundError(runId); + } + return run; +} + +function getExecutableRun(store: CodeReviewStore, runId: string, force: boolean): CodeReviewRun { + const run = store.getRun(runId); + if (!run) { + throw new CodeReviewRunNotFoundError(runId); + } + + if (run.status === "preparing" || run.status === "running") { + throw new CodeReviewRunNotExecutableError(run); + } + + if (!force && !["queued", "failed"].includes(run.status)) { + throw new CodeReviewRunNotExecutableError(run); + } + + return run; +} + +export async function executeCodeReviewRun( + { + config, + sessionManager, + storeFactory = createCodeReviewStore, + prepareWorkspace = prepareGitReviewerWorkspace, + runReviewer = runCodexCodeReview, + now = () => new Date(), + force = false, + }: ExecuteCodeReviewRunOptions, + { projectId, runId }: ExecuteCodeReviewRunInput, +): Promise { + const project = config.projects[projectId]; + if (!project) { + throw new Error(`Unknown project: ${projectId}`); + } + + const store = storeFactory(projectId); + const claimed = await withReviewRunExecutionLock(store, runId, async () => { + const executableRun = getExecutableRun(store, runId, force); + const session = await sessionManager.get(executableRun.linkedSessionId); + if (!session) { + throw new SessionNotFoundError(executableRun.linkedSessionId); + } + + const startedAt = now(); + const run = store.updateRun( + executableRun.id, + { + status: "preparing", + startedAt: executableRun.startedAt ?? startedAt.toISOString(), + completedAt: undefined, + terminationReason: undefined, + }, + startedAt, + ); + + return { run, session }; + }); + let run = claimed.run; + const session = claimed.session; + + try { + const workspacePath = await prepareWorkspace({ projectId, project, session, run }); + run = store.updateRun( + run.id, + { status: "running", reviewerWorkspacePath: workspacePath }, + now(), + ); + const baseRef = session.pr?.baseBranch?.trim() || project.defaultBranch; + const result = await runReviewer({ config, project, session, run, workspacePath, baseRef }); + const findings = result.findings ?? parseReviewerOutput(result.rawOutput ?? ""); + + for (const finding of findings) { + store.createFinding( + { + runId: run.id, + linkedSessionId: run.linkedSessionId, + severity: finding.severity ?? "warning", + title: finding.title?.trim() || "Review finding", + body: finding.body?.trim() || "Reviewer reported an issue without details.", + filePath: finding.filePath, + startLine: finding.startLine, + endLine: finding.endLine, + category: finding.category, + confidence: finding.confidence, + fingerprint: finding.fingerprint, + }, + now(), + ); + } + + const completedAt = now(); + store.updateRun( + run.id, + { + status: findings.length > 0 ? "needs_triage" : "clean", + completedAt: completedAt.toISOString(), + summary: result.summary ?? run.summary, + terminationReason: undefined, + }, + completedAt, + ); + } catch (error) { + const completedAt = now(); + store.updateRun( + run.id, + { + status: "failed", + completedAt: completedAt.toISOString(), + terminationReason: error instanceof Error ? error.message : String(error), + }, + completedAt, + ); + } + + return summarizeRun(store, run.id); +} + +export async function sendCodeReviewFindingsToAgent( + { + config, + sessionManager, + storeFactory = createCodeReviewStore, + now = () => new Date(), + }: SendCodeReviewFindingsOptions, + { projectId, runId }: SendCodeReviewFindingsInput, +): Promise { + const project = config.projects[projectId]; + if (!project) { + throw new Error(`Unknown project: ${projectId}`); + } + + const store = storeFactory(projectId); + const run = store.getRun(runId); + if (!run) { + throw new CodeReviewRunNotFoundError(runId); + } + + const session = await sessionManager.get(run.linkedSessionId); + if (!session) { + throw new SessionNotFoundError(run.linkedSessionId); + } + + const findings = store.listFindings({ runId: run.id, status: "open" }); + if (findings.length === 0) { + throw new CodeReviewNoOpenFindingsError(run); + } + + const message = formatCodeReviewFindingsForAgent({ run, findings, session }); + await sessionManager.send(session.id, message); + + const sentAt = now(); + for (const finding of findings) { + store.updateFinding( + finding.id, + { + status: "sent_to_agent", + sentToAgentAt: sentAt.toISOString(), + }, + sentAt, + ); + } + + store.updateRun(run.id, { status: "waiting_update" }, sentAt); + + return { + run: summarizeRun(store, run.id), + sentFindingCount: findings.length, + message, + }; +} diff --git a/packages/core/src/code-review-store.ts b/packages/core/src/code-review-store.ts new file mode 100644 index 000000000..5f8875314 --- /dev/null +++ b/packages/core/src/code-review-store.ts @@ -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 { + 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>(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>(); + + 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>, + 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 + >, + 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); +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 37c59d3ce..145a7b2cb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -32,6 +32,7 @@ import { loadGlobalConfig, } from "./global-config.js"; import { loadEffectiveProjectConfig } from "./project-resolver.js"; +import { recordActivityEvent } from "./activity-events.js"; function inferScmPlugin(project: { repo?: string; @@ -198,6 +199,13 @@ const NotifierConfigSchema = z .passthrough() .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "Notifier")); +const ObservabilityConfigSchema = z + .object({ + logLevel: z.enum(["debug", "info", "warn", "error"]).default("warn"), + stderr: z.boolean().default(false), + }) + .default({}); + const AgentPermissionSchema = z .enum(["permissionless", "default", "auto-edit", "suggest", "skip"]) .default("permissionless") @@ -354,6 +362,7 @@ const OrchestratorConfigSchema = z.object({ readyThresholdMs: z.number().int().nonnegative().default(300_000), power: PowerConfigSchema, lifecycle: LifecycleConfigSchema, + observability: ObservabilityConfigSchema, defaults: DefaultPluginsSchema.default({}), plugins: z.array(InstalledPluginConfigSchema).default([]), dashboard: DashboardConfigSchema.optional(), @@ -844,6 +853,7 @@ function buildEffectiveConfigFromFlatLocalPath( terminalPort: globalConfig.terminalPort, directTerminalPort: globalConfig.directTerminalPort, readyThresholdMs: globalConfig.readyThresholdMs, + observability: globalConfig.observability, defaults: globalConfig.defaults, notifiers: globalConfig.notifiers, notificationRouting: globalConfig.notificationRouting, @@ -876,6 +886,17 @@ function buildEffectiveConfigFromGlobalConfigPath(configPath: string): LoadedCon path: entry.path, resolveError: error.message, }; + if (error.reasonKind === "malformed" || error.reasonKind === "invalid") { + continue; + } + recordActivityEvent({ + projectId, + source: "config", + kind: "config.project_resolve_failed", + level: "error", + summary: `project ${projectId} failed to resolve`, + data: { path: entry.path, error: error.message }, + }); } } @@ -884,6 +905,7 @@ function buildEffectiveConfigFromGlobalConfigPath(configPath: string): LoadedCon terminalPort: globalConfig.terminalPort, directTerminalPort: globalConfig.directTerminalPort, readyThresholdMs: globalConfig.readyThresholdMs, + observability: globalConfig.observability, defaults: globalConfig.defaults, notifiers: globalConfig.notifiers, notificationRouting: globalConfig.notificationRouting, diff --git a/packages/core/src/dashboard-notifications.ts b/packages/core/src/dashboard-notifications.ts new file mode 100644 index 000000000..bf6a9f4e4 --- /dev/null +++ b/packages/core/src/dashboard-notifications.ts @@ -0,0 +1,198 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { NotifyAction, OrchestratorEvent } from "./types.js"; +import type { NotificationDataV3 } from "./notification-data.js"; +import { atomicWriteFileSync } from "./atomic-write.js"; +import { getObservabilityBaseDir } from "./paths.js"; + +export const DEFAULT_DASHBOARD_NOTIFICATION_LIMIT = 50; +export const MAX_DASHBOARD_NOTIFICATION_LIMIT = 500; + +export interface LegacyDashboardNotificationData { + [key: string]: unknown; +} + +export type DashboardNotificationEventData = NotificationDataV3 | LegacyDashboardNotificationData; + +export interface SerializedDashboardEvent { + id: string; + type: string; + priority: string; + sessionId: string; + projectId: string; + timestamp: string; + message: string; + data: DashboardNotificationEventData; +} + +export interface SerializedDashboardAction { + label: string; + url?: string; + callbackEndpoint?: string; +} + +export interface DashboardNotificationRecord { + id: string; + receivedAt: string; + event: SerializedDashboardEvent; + actions?: SerializedDashboardAction[]; +} + +export interface AppendDashboardNotificationOptions { + limit?: unknown; + receivedAt?: Date; +} + +export function normalizeDashboardNotificationLimit(value: unknown): number { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number.parseInt(value, 10) + : DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + + if (!Number.isFinite(parsed)) return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + return Math.min(MAX_DASHBOARD_NOTIFICATION_LIMIT, Math.max(1, Math.floor(parsed))); +} + +export function getDashboardNotificationStorePath(configPath: string): string { + return join(getObservabilityBaseDir(configPath), "dashboard-notifications.jsonl"); +} + +function toJsonRecord(value: unknown): DashboardNotificationEventData { + try { + const serialized = JSON.parse(JSON.stringify(value ?? {})) as unknown; + if (serialized && typeof serialized === "object" && !Array.isArray(serialized)) { + return serialized as DashboardNotificationEventData; + } + } catch { + // Fall through to a small marker below. Notifications should not fail + // because one event payload contained a non-serializable value. + } + + return { serializationError: "event data could not be serialized" }; +} + +function serializeAction(action: NotifyAction): SerializedDashboardAction { + return { + label: action.label, + ...(typeof action.url === "string" ? { url: action.url } : {}), + ...(typeof action.callbackEndpoint === "string" + ? { callbackEndpoint: action.callbackEndpoint } + : {}), + }; +} + +export function createDashboardNotificationRecord( + event: OrchestratorEvent, + actions?: NotifyAction[], + receivedAt = new Date(), +): DashboardNotificationRecord { + const receivedAtIso = receivedAt.toISOString(); + return { + id: `${event.id}:${receivedAtIso}`, + receivedAt: receivedAtIso, + event: { + id: event.id, + type: event.type, + priority: event.priority, + sessionId: event.sessionId, + projectId: event.projectId, + timestamp: event.timestamp.toISOString(), + message: event.message, + data: toJsonRecord(event.data), + }, + ...(actions && actions.length > 0 ? { actions: actions.map(serializeAction) } : {}), + }; +} + +function isDashboardNotificationRecord(value: unknown): value is DashboardNotificationRecord { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + const event = candidate.event as Partial | undefined; + return ( + typeof candidate.id === "string" && + typeof candidate.receivedAt === "string" && + event !== undefined && + typeof event.id === "string" && + typeof event.type === "string" && + typeof event.priority === "string" && + typeof event.sessionId === "string" && + typeof event.projectId === "string" && + typeof event.timestamp === "string" && + typeof event.message === "string" && + event.data !== undefined && + typeof event.data === "object" && + !Array.isArray(event.data) + ); +} + +export function readDashboardNotificationsFromFile( + filePath: string, + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): DashboardNotificationRecord[] { + if (!existsSync(filePath)) return []; + + const normalizedLimit = normalizeDashboardNotificationLimit(limit); + const content = readFileSync(filePath, "utf-8"); + const records: DashboardNotificationRecord[] = []; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (isDashboardNotificationRecord(parsed)) { + records.push(parsed); + } + } catch { + // Ignore malformed lines. A partial write should not break the dashboard. + } + } + + return records.slice(-normalizedLimit); +} + +export function readDashboardNotifications( + configPath: string, + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): DashboardNotificationRecord[] { + return readDashboardNotificationsFromFile(getDashboardNotificationStorePath(configPath), limit); +} + +export function writeDashboardNotificationsToFile( + filePath: string, + records: DashboardNotificationRecord[], + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): void { + const normalizedLimit = normalizeDashboardNotificationLimit(limit); + const retained = records.slice(-normalizedLimit); + mkdirSync(dirname(filePath), { recursive: true }); + const content = + retained.length > 0 ? `${retained.map((record) => JSON.stringify(record)).join("\n")}\n` : ""; + atomicWriteFileSync(filePath, content); +} + +export function appendDashboardNotificationRecord( + filePath: string, + record: DashboardNotificationRecord, + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): DashboardNotificationRecord { + const existing = readDashboardNotificationsFromFile(filePath, limit); + writeDashboardNotificationsToFile(filePath, [...existing, record], limit); + return record; +} + +export function appendDashboardNotification( + configPath: string, + event: OrchestratorEvent, + actions?: NotifyAction[], + options: AppendDashboardNotificationOptions = {}, +): DashboardNotificationRecord { + const record = createDashboardNotificationRecord(event, actions, options.receivedAt); + return appendDashboardNotificationRecord( + getDashboardNotificationStorePath(configPath), + record, + options.limit, + ); +} diff --git a/packages/core/src/events-db.ts b/packages/core/src/events-db.ts index 71444a9a3..ca04f0062 100644 --- a/packages/core/src/events-db.ts +++ b/packages/core/src/events-db.ts @@ -24,6 +24,7 @@ type BetterSqlite3Database = { let _db: BetterSqlite3Database | null = null; let _dbFailed = false; let _ftsEnabled = false; +let _dbUnavailableWarningEmitted = false; const PRUNE_BATCH_SIZE = 1000; function getEventsDbPath(): string { @@ -90,14 +91,12 @@ function initFts(db: BetterSqlite3Database): void { } function pruneOldEvents(db: BetterSqlite3Database, 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); } function openDb(): BetterSqlite3Database { @@ -161,6 +160,42 @@ export function closeDb(): void { _ftsEnabled = false; } +function isAoEventsInvocation(argv = process.argv): boolean { + return argv.slice(2).includes("events"); +} + +function isMissingBetterSqlite3Binding(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes("Could not locate the bindings file") || + message.includes("better_sqlite3.node") || + message.includes("Cannot find module 'better-sqlite3'") + ); +} + +function firstErrorLine(err: unknown): string { + return (err instanceof Error ? err.message : String(err)).split(/\r?\n/, 1)[0] ?? "unknown error"; +} + +export function formatActivityEventsDbUnavailableWarning(err: unknown): string { + if (isMissingBetterSqlite3Binding(err)) { + return `[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.`; + } + return `[ao] activity-events disabled: better-sqlite3 failed to load: ${firstErrorLine(err)}`; +} + +export function emitActivityEventsDbUnavailableWarning(err: unknown): void { + if (_dbUnavailableWarningEmitted) return; + if (process.env["AO_DEBUG"] !== "1" && !isAoEventsInvocation()) return; + _dbUnavailableWarningEmitted = true; + // eslint-disable-next-line no-console + console.warn(formatActivityEventsDbUnavailableWarning(err)); +} + +export function __resetActivityEventsDbWarningForTests(): void { + _dbUnavailableWarningEmitted = false; +} + /** * Get the lazily-initialized DB connection. * Returns null if better-sqlite3 failed to load or init — callers should treat null as no-op. @@ -173,8 +208,7 @@ export function getDb(): BetterSqlite3Database | null { return _db; } catch (err) { _dbFailed = true; - // Log once so operators know events are being dropped; subsequent calls return null silently. - console.warn("[ao] activity-events DB unavailable — events will be dropped:", err instanceof Error ? err.message : String(err)); + emitActivityEventsDbUnavailableWarning(err); return null; } } diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index bb9811d7b..acb49bdd6 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -7,10 +7,11 @@ import { z } from "zod"; import { atomicWriteFileSync } from "./atomic-write.js"; import { detectScmPlatform } from "./config-generator.js"; import { withFileLockSync } from "./file-lock.js"; -import { ProjectResolveError } from "./types.js"; +import { ProjectResolveError, type ProjectResolveErrorKind } from "./types.js"; import { generateSessionPrefix } from "./paths.js"; import { normalizeOriginUrl } from "./storage-key.js"; import { getDefaultRuntime } from "./platform.js"; +import { recordActivityEvent } from "./activity-events.js"; function globalConfigLockPath(configPath: string): string { return `${configPath}.lock`; @@ -212,6 +213,13 @@ export const GlobalConfigSchema = z password: z.string().optional(), }) .optional(), + /** Structured observability defaults. Env vars still override at runtime. */ + observability: z + .object({ + logLevel: z.enum(["debug", "info", "warn", "error"]).default("warn"), + stderr: z.boolean().default(false), + }) + .optional(), /** Cross-project defaults — projects inherit when fields are omitted. */ defaults: z .object({ @@ -354,6 +362,12 @@ export function loadGlobalConfig( if (migrationSummary) { // eslint-disable-next-line no-console -- required migration visibility for stale shadow stripping console.info(migrationSummary); + recordActivityEvent({ + source: "config", + kind: "config.migrated", + summary: "global config migrated", + data: { migrationSummary }, + }); } const config = GlobalConfigSchema.parse(parsed); @@ -472,6 +486,69 @@ export function writeLocalProjectConfig( return configPath; } +function isRecord(value: unknown): value is Record { + return value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value); +} + +function mergeRoleBehavior( + defaults: Record, + project: Record, + key: "orchestrator" | "worker", +): Record | undefined { + const defaultRole = isRecord(defaults[key]) ? defaults[key] : undefined; + const projectRole = isRecord(project[key]) ? project[key] : undefined; + const merged = { + ...(defaultRole ?? {}), + ...(projectRole ?? {}), + }; + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function buildRepairedLocalProjectConfig( + parsed: Record, + project: Record, +): Record { + const defaults = isRecord(parsed["defaults"]) ? parsed["defaults"] : {}; + const defaultBehavior: Record = {}; + for (const key of ["runtime", "agent", "workspace"] as const) { + if (defaults[key] !== null && defaults[key] !== undefined) { + defaultBehavior[key] = defaults[key]; + } + } + + const { + name: _name, + path: _path, + sessionPrefix: _sessionPrefix, + projectId: _projectId, + source: _source, + registeredAt: _registeredAt, + displayName: _displayName, + orchestrator: _orchestrator, + worker: _worker, + ...projectBehavior + } = project; + void _name; + void _path; + void _sessionPrefix; + void _projectId; + void _source; + void _registeredAt; + void _displayName; + void _orchestrator; + void _worker; + + const behavior = { + ...defaultBehavior, + ...projectBehavior, + }; + const orchestrator = mergeRoleBehavior(defaults, project, "orchestrator"); + const worker = mergeRoleBehavior(defaults, project, "worker"); + if (orchestrator) behavior["orchestrator"] = orchestrator; + if (worker) behavior["worker"] = worker; + return behavior; +} + export function repairWrappedLocalProjectConfig(projectId: string, projectPath: string): void { const localConfigResult = loadLocalProjectConfigDetailed(projectPath); if (localConfigResult.kind !== "old-format" || !localConfigResult.path) { @@ -501,24 +578,7 @@ export function repairWrappedLocalProjectConfig(projectId: string, projectPath: ); } - const { - name: _name, - path: _path, - sessionPrefix: _sessionPrefix, - projectId: _projectId, - source: _source, - registeredAt: _registeredAt, - displayName: _displayName, - ...behaviorFields - } = project; - void _name; - void _path; - void _sessionPrefix; - void _projectId; - void _source; - void _registeredAt; - void _displayName; - + const behaviorFields = buildRepairedLocalProjectConfig(parsed, project); writeLocalProjectConfig(projectPath, behaviorFields, configPath); } @@ -843,6 +903,7 @@ export function resolveProjectIdentity( defaultBranch: string; sessionPrefix: string; resolveError?: string; + resolveErrorKind?: ProjectResolveErrorKind; }) | null { const entry = globalConfig.projects[projectId] as @@ -921,15 +982,48 @@ export function resolveProjectIdentity( }; } + if (localConfigResult.kind === "malformed") { + recordActivityEvent({ + projectId, + source: "config", + kind: "config.project_malformed", + level: "error", + summary: `local config for ${projectId} could not be parsed`, + data: { + path: localConfigResult.path, + error: localConfigResult.error, + }, + }); + } else if (localConfigResult.kind === "invalid") { + recordActivityEvent({ + projectId, + source: "config", + kind: "config.project_invalid", + level: "error", + summary: `local config for ${projectId} failed validation`, + data: { + path: localConfigResult.path, + error: localConfigResult.error, + }, + }); + } + const resolveError = localConfigResult.kind !== "missing" ? (localConfigResult.error ?? "Failed to load local config") : undefined; + const resolveErrorKind: ProjectResolveErrorKind | undefined = + localConfigResult.kind === "malformed" || + localConfigResult.kind === "invalid" || + localConfigResult.kind === "old-format" + ? localConfigResult.kind + : undefined; return { ...(resolveError ? {} : applyBehaviorDefaults({})), ...identityFields, ...(resolveError ? { resolveError } : {}), + ...(resolveErrorKind ? { resolveErrorKind } : {}), }; } @@ -998,6 +1092,8 @@ export function migrateToGlobalConfig(oldConfigPath: string, globalConfigPath?: newGlobal.directTerminalPort = parsed["directTerminalPort"] as number; if (parsed["readyThresholdMs"] !== null && parsed["readyThresholdMs"] !== undefined) newGlobal.readyThresholdMs = parsed["readyThresholdMs"] as number; + if (parsed["observability"] !== null && parsed["observability"] !== undefined) + newGlobal.observability = parsed["observability"] as GlobalConfig["observability"]; if (parsed["defaults"] !== null && parsed["defaults"] !== undefined) newGlobal.defaults = parsed["defaults"] as GlobalConfig["defaults"]; if (parsed["notifiers"] !== null && parsed["notifiers"] !== undefined) @@ -1091,6 +1187,10 @@ export function createDefaultGlobalConfig(): GlobalConfig { remoteAccess: { username: "ao", }, + observability: { + logLevel: "warn", + stderr: false, + }, defaults: { runtime: getDefaultRuntime(), agent: "claude-code", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3c133025..aa7754736 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,6 +43,53 @@ export { export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; export { sessionFromMetadata } from "./utils/session-from-metadata.js"; +// AO-local code review store +export { CodeReviewStore, createCodeReviewStore } from "./code-review-store.js"; +export type { + CodeReviewFinding, + CodeReviewFindingStatus, + CodeReviewRun, + CodeReviewRunStatus, + CodeReviewRunSummary, + CodeReviewSeverity, + CodeReviewStoreOptions, + CreateCodeReviewFindingInput, + CreateCodeReviewRunInput, + ListCodeReviewFindingsFilter, + ListCodeReviewRunsFilter, +} from "./code-review-store.js"; +export { + CodeReviewInvalidSessionError, + CodeReviewNoOpenFindingsError, + CodeReviewRunNotExecutableError, + CodeReviewRunNotFoundError, + createShellCodeReviewRunner, + executeCodeReviewRun, + formatCodeReviewFindingsForAgent, + markOutdatedCodeReviewRunsForSession, + parseReviewerOutput, + prepareGitReviewerWorkspace, + runCodexCodeReview, + sendCodeReviewFindingsToAgent, + triggerCodeReviewForSession, +} from "./code-review-manager.js"; +export type { + CodeReviewRunner, + CodeReviewRunnerContext, + CodeReviewRunnerFinding, + CodeReviewRunnerResult, + CodeReviewRequestSource, + ExecuteCodeReviewRunInput, + ExecuteCodeReviewRunOptions, + MarkOutdatedCodeReviewRunsInput, + PrepareCodeReviewWorkspace, + SendCodeReviewFindingsInput, + SendCodeReviewFindingsOptions, + SendCodeReviewFindingsResult, + TriggerCodeReviewInput, + TriggerCodeReviewOptions, +} from "./code-review-manager.js"; + // Lifecycle transitions — centralized transition boundary (#137) export { applyLifecycleDecision, @@ -212,6 +259,43 @@ export { } from "./observability.js"; export { execGhObserved, getGhTraceFilePath } from "./gh-trace.js"; export { resolveNotifierTarget } from "./notifier-resolution.js"; +export { + recordNotificationDelivery, + sanitizeNotificationDeliveryReason, +} from "./notification-observability.js"; +export { + NOTIFICATION_DATA_SCHEMA_VERSION, + buildCIFailureNotificationData, + buildNotificationSubject, + buildPRStateNotificationData, + buildReactionEscalationNotificationData, + buildReactionNotificationData, + buildSessionTransitionNotificationData, + getNotificationDataV3, + semanticTypeForReactionKey, +} from "./notification-data.js"; +export type { + CIFailureNotificationInput, + NotificationCI, + NotificationCICheck, + NotificationDataBaseInput, + NotificationDataV3, + NotificationEscalation, + NotificationEventContext, + NotificationIssueSubject, + NotificationMerge, + NotificationPRContext, + NotificationPRSubject, + NotificationReaction, + NotificationReview, + NotificationSessionSubject, + NotificationSubject, + NotificationTransition, + PRStateNotificationInput, + ReactionEscalationNotificationInput, + ReactionNotificationInput, + SessionTransitionNotificationInput, +} from "./notification-data.js"; export type { ObservabilityLevel, ObservabilityMetricName, @@ -220,6 +304,12 @@ export type { ProjectObserver, } from "./observability.js"; export type { GhTraceContext, GhTraceEntry } from "./gh-trace.js"; +export type { + NotificationDeliveryFailureKind, + NotificationDeliveryMethod, + NotificationDeliveryTarget, + RecordNotificationDeliveryInput, +} from "./notification-observability.js"; // Feedback tools — contracts, validation, and report storage export { @@ -246,6 +336,7 @@ export { getProjectDir, getProjectSessionsDir, getProjectWorktreesDir, + getProjectCodeReviewsDir, getProjectFeedbackReportsDir, getOrchestratorPath, getSessionPath, @@ -284,6 +375,24 @@ export { export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js"; +export { + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + MAX_DASHBOARD_NOTIFICATION_LIMIT, + appendDashboardNotification, + appendDashboardNotificationRecord, + createDashboardNotificationRecord, + getDashboardNotificationStorePath, + normalizeDashboardNotificationLimit, + readDashboardNotifications, + readDashboardNotificationsFromFile, + writeDashboardNotificationsToFile, + type DashboardNotificationEventData, + type DashboardNotificationRecord, + type LegacyDashboardNotificationData, + type SerializedDashboardAction, + type SerializedDashboardEvent, +} from "./dashboard-notifications.js"; + // Global config — Option C hybrid architecture (global registry + local behavior) export { getGlobalConfigPath, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index e4193ecbf..9fa1a928f 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -69,6 +69,7 @@ import { } from "./report-watcher.js"; import { createCorrelationId, createProjectObserver } from "./observability.js"; import { resolveNotifierTarget } from "./notifier-resolution.js"; +import { recordNotificationDelivery } from "./notification-observability.js"; import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; import { DETECTING_MAX_ATTEMPTS, @@ -80,6 +81,14 @@ import { resolveProbeDecision, type LifecycleDecision, } from "./lifecycle-status-decisions.js"; +import { + buildCIFailureNotificationData, + buildPRStateNotificationData, + buildReactionEscalationNotificationData, + buildReactionNotificationData, + buildSessionTransitionNotificationData, + type NotificationEventContext, +} from "./notification-data.js"; /** Parse a duration string like "10m", "30s", "1h" to milliseconds. */ function parseDuration(str: string): number { @@ -286,23 +295,7 @@ function prStateToEventType( } /** PR context for event enrichment. */ -interface EventPRContext { - url: string; - /** Actual PR title from enrichment cache. null until cache is populated. */ - title: string | null; - number: number; - branch: string; -} - -/** Event context with PR and issue information for webhook payloads. */ -interface EventContext { - pr: EventPRContext | null; - issueId: string | null; - issueTitle: string | null; - /** Agent task summary (NOT the PR title). May describe the work before a PR exists. */ - summary: string | null; - branch: string | null; -} +type EventContext = NotificationEventContext; /** * Minimal session context required for reaction execution and event enrichment. @@ -327,7 +320,7 @@ function buildEventContext( session: Session | ReactionSessionContext, prEnrichmentCache: Map, ): EventContext { - let pr: EventPRContext | null = null; + let pr: EventContext["pr"] = null; if (session.pr) { const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; @@ -338,6 +331,10 @@ function buildEventContext( title: cached?.title ?? null, number: session.pr.number, branch: session.pr.branch, + baseBranch: session.pr.baseBranch, + owner: session.pr.owner, + repo: session.pr.repo, + isDraft: session.pr.isDraft, }; } @@ -510,6 +507,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan */ const prEnrichmentCache = new Map(); + function getPREnrichmentForSession( + session: Session | ReactionSessionContext, + ): PREnrichmentData | undefined { + if (!session.pr) return undefined; + return prEnrichmentCache.get(`${session.pr.owner}/${session.pr.repo}#${session.pr.number}`); + } + /** Repos where Guard 1 returned 304 in the current poll — safe to skip detectPR. */ let prListUnchangedRepos = new Set(); @@ -671,7 +675,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // When Guard 1 returned 304, the repo is in prListUnchangedRepos — no new PRs exist. for (const session of sessions) { if (!session.branch) continue; - if (session.metadata["prAutoDetect"] === "off" || session.metadata["prAutoDetect"] === "false") continue; + if ( + session.metadata["prAutoDetect"] === "off" || + session.metadata["prAutoDetect"] === "false" + ) + continue; if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) continue; if ( @@ -1316,16 +1324,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan ) { const mapped = mapAgentReportToLifecycle(agentReport.state); return commit({ - status: deriveLegacyStatus( - { - ...lifecycle, - session: { - ...lifecycle.session, - state: mapped.sessionState, - reason: mapped.sessionReason, - }, + status: deriveLegacyStatus({ + ...lifecycle, + session: { + ...lifecycle.session, + state: mapped.sessionState, + reason: mapped.sessionReason, }, - ), + }), evidence: `agent_report:${agentReport.state}`, detecting: { attempts: 0 }, sessionState: mapped.sessionState, @@ -1455,6 +1461,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan : typeof escalateAfter === "number" && tracker.attempts > escalateAfter ? "max_attempts" : "max_duration"; + const durationMs = Date.now() - tracker.firstTriggered.getTime(); recordActivityEvent({ projectId, sessionId, @@ -1465,7 +1472,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { reactionKey, attempts: tracker.attempts, - durationSinceFirstMs: Date.now() - tracker.firstTriggered.getTime(), + durationSinceFirstMs: durationMs, escalationCause, }, }); @@ -1475,7 +1482,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId, projectId, message: `Reaction '${reactionKey}' escalated after ${tracker.attempts} attempts`, - data: { reactionKey, attempts: tracker.attempts, context, schemaVersion: 2 }, + data: buildReactionEscalationNotificationData({ + eventType: "reaction.escalated", + sessionId, + projectId, + context, + reactionKey, + action: "escalated", + attempts: tracker.attempts, + cause: escalationCause, + durationMs, + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, reactionConfig.priority ?? "urgent"); @@ -1545,8 +1563,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const event = createEvent("reaction.triggered", { sessionId, projectId, - message: `Reaction '${reactionKey}' triggered notification`, - data: { reactionKey, context, schemaVersion: 2 }, + message: reactionConfig.message ?? `Reaction '${reactionKey}' triggered notification`, + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId, + projectId, + context, + reactionKey, + action: "notify", + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, reactionConfig.priority ?? "info"); recordActivityEvent({ @@ -1572,8 +1598,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const event = createEvent("reaction.triggered", { sessionId, projectId, - message: `Reaction '${reactionKey}' triggered auto-merge`, - data: { reactionKey, context, schemaVersion: 2 }, + message: reactionConfig.message ?? `Reaction '${reactionKey}' triggered auto-merge`, + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId, + projectId, + context, + reactionKey, + action: "auto-merge", + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, "action"); recordActivityEvent({ @@ -1623,9 +1657,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (!project) return; const sessionsDir = getProjectSessionsDir(session.projectId); - const lifecycleUpdates = buildLifecycleMetadataPatch( - cloneLifecycle(session.lifecycle), - ); + const lifecycleUpdates = buildLifecycleMetadataPatch(cloneLifecycle(session.lifecycle)); const mergedUpdates = { ...updates, ...lifecycleUpdates }; updateMetadata(sessionsDir, session.id, mergedUpdates); sessionManager.invalidateCache(); @@ -2117,7 +2149,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId: session.id, projectId: session.projectId, message: detailedMessage, - data: { failedChecks: failedChecks.map((c) => c.name), context, schemaVersion: 2 }, + data: buildCIFailureNotificationData({ + sessionId: session.id, + projectId: session.projectId, + context, + failedChecks, + }), }); await notifyHuman(event, reactionConfig.priority ?? "warning"); } @@ -2243,12 +2280,40 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const notifier = registry.get("notifier", target.reference) ?? registry.get("notifier", target.pluginName); - if (notifier) { - try { - await notifier.notify(eventWithPriority); - } catch { - // Notifier failed — not much we can do - } + if (!notifier) { + recordNotificationDelivery({ + observer, + event: eventWithPriority, + target, + outcome: "failure", + method: "notify", + reason: "notifier target not found", + failureKind: "target_missing", + recordActivityEvent: true, + }); + continue; + } + + try { + await notifier.notify(eventWithPriority); + recordNotificationDelivery({ + observer, + event: eventWithPriority, + target, + outcome: "success", + method: "notify", + }); + } catch (err) { + recordNotificationDelivery({ + observer, + event: eventWithPriority, + target, + outcome: "failure", + method: "notify", + reason: err instanceof Error ? err.message : String(err), + failureKind: "delivery_failed", + recordActivityEvent: true, + }); } } } @@ -2310,9 +2375,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan activity, // Elapsed wall-time since cleanup was first deferred. NOT a Unix // timestamp — naming it `pendingSinceMs` was misleading (Greptile). - pendingElapsedMs: Number.isFinite(pendingSinceMs) - ? Date.now() - pendingSinceMs - : null, + pendingElapsedMs: Number.isFinite(pendingSinceMs) ? Date.now() - pendingSinceMs : null, graceMs, }, }); @@ -2608,7 +2671,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId: session.id, projectId: session.projectId, message: `${session.id}: ${oldStatus} → ${newStatus}`, - data: { oldStatus, newStatus, context, schemaVersion: 2 }, + data: buildSessionTransitionNotificationData({ + eventType, + sessionId: session.id, + projectId: session.projectId, + context, + oldStatus, + newStatus, + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, priority); } @@ -2661,15 +2732,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId: session.id, projectId: session.projectId, message: `${session.id}: PR ${previousPRState} → ${session.lifecycle.pr.state}`, - data: { + data: buildPRStateNotificationData({ + eventType: prEventType, + sessionId: session.id, + projectId: session.projectId, + context, oldPRState: previousPRState, newPRState: session.lifecycle.pr.state, - // prNumber/prUrl kept for backward compat — drop in schemaVersion 3 - prNumber: session.lifecycle.pr.number, - prUrl: session.lifecycle.pr.url, - context, - schemaVersion: 2, - }, + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(prEvent, inferPriority(prEventType)); } diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index a4f3a19c5..c93164f5c 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -22,9 +22,15 @@ import { closeSync, constants, } from "node:fs"; -import { join, dirname } from "node:path"; -import type { CanonicalSessionLifecycle, RuntimeHandle, SessionId, SessionMetadata } from "./types.js"; +import { basename, join, dirname } from "node:path"; +import type { + CanonicalSessionLifecycle, + RuntimeHandle, + SessionId, + SessionMetadata, +} from "./types.js"; import { atomicWriteFileSync } from "./atomic-write.js"; +import { recordActivityEvent, type ActivityEventSource } from "./activity-events.js"; import { buildLifecycleMetadataPatch, cloneLifecycle, @@ -94,7 +100,9 @@ function parseRuntimeHandleField(value: unknown): RuntimeHandle | undefined { if (typeof parsed["id"] === "string" && typeof parsed["runtimeName"] === "string") { return parsed as unknown as RuntimeHandle; } - } catch { /* not valid JSON */ } + } catch { + /* not valid JSON */ + } } return undefined; } @@ -106,13 +114,16 @@ function parseDashboardField(raw: Record): SessionMetadata["das return { port: typeof d["port"] === "number" ? d["port"] : undefined, terminalWsPort: typeof d["terminalWsPort"] === "number" ? d["terminalWsPort"] : undefined, - directTerminalWsPort: typeof d["directTerminalWsPort"] === "number" ? d["directTerminalWsPort"] : undefined, + directTerminalWsPort: + typeof d["directTerminalWsPort"] === "number" ? d["directTerminalWsPort"] : undefined, }; } // Legacy format: flat fields const port = typeof raw["dashboardPort"] === "number" ? raw["dashboardPort"] : undefined; - const terminalWsPort = typeof raw["terminalWsPort"] === "number" ? raw["terminalWsPort"] : undefined; - const directTerminalWsPort = typeof raw["directTerminalWsPort"] === "number" ? raw["directTerminalWsPort"] : undefined; + const terminalWsPort = + typeof raw["terminalWsPort"] === "number" ? raw["terminalWsPort"] : undefined; + const directTerminalWsPort = + typeof raw["directTerminalWsPort"] === "number" ? raw["directTerminalWsPort"] : undefined; if (port !== undefined || terminalWsPort !== undefined || directTerminalWsPort !== undefined) { return { port, terminalWsPort, directTerminalWsPort }; } @@ -159,8 +170,15 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta issueTitle: raw["issueTitle"] as string | undefined, pr: raw["pr"] as string | undefined, prAutoDetect: - raw["prAutoDetect"] === "off" || raw["prAutoDetect"] === "false" || raw["prAutoDetect"] === false ? false : - raw["prAutoDetect"] === "on" || raw["prAutoDetect"] === "true" || raw["prAutoDetect"] === true ? true : undefined, + raw["prAutoDetect"] === "off" || + raw["prAutoDetect"] === "false" || + raw["prAutoDetect"] === false + ? false + : raw["prAutoDetect"] === "on" || + raw["prAutoDetect"] === "true" || + raw["prAutoDetect"] === true + ? true + : undefined, summary: raw["summary"] as string | undefined, project: raw["project"] as string | undefined, agent: raw["agent"] as string | undefined, @@ -220,8 +238,12 @@ export function readMetadataRaw( /** Fields that are stored as JSON objects and should be parsed when unflattening. */ const jsonFields = new Set([ - "runtimeHandle", "lifecycle", "statePayload", "dashboard", - "agentReport", "reportWatcher", + "runtimeHandle", + "lifecycle", + "statePayload", + "dashboard", + "agentReport", + "reportWatcher", ]); /** Unflatten a Record to proper types for JSON storage. */ @@ -233,7 +255,12 @@ function unflattenFromStringRecord(data: Record): Record>, ): void { - mutateMetadata(dataDir, sessionId, (existing) => { - return applyMetadataUpdates(existing, updates); - }, { createIfMissing: true }); + mutateMetadata( + dataDir, + sessionId, + (existing) => { + return applyMetadataUpdates(existing, updates); + }, + { createIfMissing: true }, + ); } export function applyMetadataUpdates( @@ -336,59 +368,94 @@ function normalizeMetadataRecord(data: Record): Record) => Record, - options: { createIfMissing?: boolean } = {}, + options: MutateMetadataOptions = {}, ): Record | null { const path = metadataPath(dataDir, sessionId); const lockPath = `${path}.lock`; - return withFileLockSync(lockPath, () => { - let existing: Record = {}; + return withFileLockSync( + lockPath, + () => { + let existing: Record = {}; - let content: string | undefined; - try { - content = readFileSync(path, "utf-8").trim(); - } catch { - // File doesn't exist - } + let content: string | undefined; + try { + content = readFileSync(path, "utf-8").trim(); + } catch { + // File doesn't exist + } - if (content !== undefined) { - if (content) { - const raw = parseMetadataContent(content); - if (raw) { - existing = flattenToStringRecord(raw); - } else { - // Corrupt JSON. Preserve forensic evidence by side-renaming - // the file before we overwrite it with the merged update. - // Without this, the very next mutateMetadata call destroys - // the corrupt bytes permanently and the user has no signal - // that anything was wrong — the file just becomes "not - // corrupt anymore — and missing fields". - const corruptPath = `${path}.corrupt-${Date.now()}`; - try { - renameSync(path, corruptPath); - // eslint-disable-next-line no-console - console.warn( - `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, - ); - } catch { - // best effort — proceed even if the rename fails (e.g. EACCES) + if (content !== undefined) { + if (content) { + const raw = parseMetadataContent(content); + if (raw) { + existing = flattenToStringRecord(raw); + } else { + // Corrupt JSON. Preserve forensic evidence by side-renaming + // the file before we overwrite it with the merged update. + // Without this, the very next mutateMetadata call destroys + // the corrupt bytes permanently and the user has no signal + // that anything was wrong — the file just becomes "not + // corrupt anymore — and missing fields". + const corruptPath = `${path}.corrupt-${Date.now()}`; + let renamed = false; + try { + renameSync(path, corruptPath); + renamed = true; + // eslint-disable-next-line no-console + console.warn( + `[metadata] corrupt JSON at ${path}; preserved as ${corruptPath} before rewriting`, + ); + } catch { + // best effort — proceed even if the rename fails (e.g. EACCES) + } + // Forensic activity event so RCA can find every silent overwrite. + // Truncate the bad-JSON sample to 200 chars (B11 invariant — full file + // could be 16KB+ and would be dropped by the sanitizer cap). + const contentSample = content.length > 200 ? content.slice(0, 200) : content; + // dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering. + const inferredProjectId = basename(dirname(dataDir)); + const summary = renamed + ? `Corrupt metadata for session ${sessionId} renamed to ${basename(corruptPath)}` + : `Corrupt metadata detected for session ${sessionId}; failed to rename forensic copy before rewrite`; + recordActivityEvent({ + projectId: inferredProjectId || undefined, + sessionId, + source: options.activityEventSource ?? "session-manager", + kind: "metadata.corrupt_detected", + level: "error", + summary, + data: { + path, + renamedTo: renamed ? corruptPath : null, + renameSucceeded: renamed, + contentSample, + contentLength: content.length, + }, + }); } } + } else if (!options.createIfMissing) { + return null; } - } else if (!options.createIfMissing) { - return null; - } - const next = normalizeMetadataRecord(updater({ ...existing })); + const next = normalizeMetadataRecord(updater({ ...existing })); - mkdirSync(dirname(path), { recursive: true }); - atomicWriteFileSync(path, serializeMetadata(unflattenFromStringRecord(next))); - return next; - }, { timeoutMs: 5_000, staleMs: 30_000 }); + mkdirSync(dirname(path), { recursive: true }); + atomicWriteFileSync(path, serializeMetadata(unflattenFromStringRecord(next))); + return next; + }, + { timeoutMs: 5_000, staleMs: 30_000 }, + ); } export function readCanonicalLifecycle( @@ -405,11 +472,7 @@ export function writeCanonicalLifecycle( sessionId: SessionId, lifecycle: CanonicalSessionLifecycle, ): void { - updateMetadata( - dataDir, - sessionId, - buildLifecycleMetadataPatch(cloneLifecycle(lifecycle)), - ); + updateMetadata(dataDir, sessionId, buildLifecycleMetadataPatch(cloneLifecycle(lifecycle))); } export function updateCanonicalLifecycle( @@ -450,18 +513,20 @@ export function listMetadata(dataDir: string): SessionId[] { const dir = dataDir; if (!existsSync(dir)) return []; - return readdirSync(dir).filter((name) => { - // Must be a .json file - if (!name.endsWith(JSON_EXTENSION)) return false; - const baseName = name.slice(0, -JSON_EXTENSION.length); - if (!baseName || baseName.startsWith(".")) return false; - if (!SESSION_ID_COMPONENT_PATTERN.test(baseName)) return false; - try { - return statSync(join(dir, name)).isFile(); - } catch { - return false; - } - }).map((name) => name.slice(0, -JSON_EXTENSION.length)); + return readdirSync(dir) + .filter((name) => { + // Must be a .json file + if (!name.endsWith(JSON_EXTENSION)) return false; + const baseName = name.slice(0, -JSON_EXTENSION.length); + if (!baseName || baseName.startsWith(".")) return false; + if (!SESSION_ID_COMPONENT_PATTERN.test(baseName)) return false; + try { + return statSync(join(dir, name)).isFile(); + } catch { + return false; + } + }) + .map((name) => name.slice(0, -JSON_EXTENSION.length)); } /** diff --git a/packages/core/src/migration/storage-v2.ts b/packages/core/src/migration/storage-v2.ts index 3572e578a..d8df92aac 100644 --- a/packages/core/src/migration/storage-v2.ts +++ b/packages/core/src/migration/storage-v2.ts @@ -30,6 +30,7 @@ import { parseKeyValueContent } from "../key-value.js"; import { generateSessionPrefix } from "../paths.js"; import { atomicWriteFileSync } from "../atomic-write.js"; import { withFileLockSync } from "../file-lock.js"; +import { recordActivityEvent } from "../activity-events.js"; // --------------------------------------------------------------------------- // Constants @@ -1209,6 +1210,16 @@ export async function migrateStorage(options: MigrationOptions = {}): Promise 0) { + recordActivityEvent({ + source: "migration", + kind: "migration.blocked", + level: "warn", + summary: `migration blocked by ${activeSessions.length} active session(s)`, + data: { + activeSessionCount: activeSessions.length, + sample: activeSessions.slice(0, 5), + }, + }); throw new Error( `Found ${activeSessions.length} active AO tmux session(s): ${activeSessions.slice(0, 5).join(", ")}${activeSessions.length > 5 ? "..." : ""}. ` + `Kill active sessions first (ao session kill --all) or use --force to migrate anyway.`, @@ -1228,7 +1239,7 @@ export async function migrateStorage(options: MigrationOptions = {}): Promise 0 ? "warn" : "info", + summary: + projectErrors.length > 0 + ? `migration finished with ${projectErrors.length} error(s)` + : `migration completed: ${totals.projects} project(s), ${totals.sessions} session(s)`, + data: { + dryRun, + projectsMigrated: totals.projects, + sessions: totals.sessions, + worktrees: totals.worktrees, + strayWorktreesMoved: totals.strayWorktreesMoved, + claudeSessionsRelinked: totals.claudeSessionsRelinked, + codexSessionsRewritten: totals.codexSessionsRewritten, + emptyDirsDeleted: totals.emptyDirsDeleted, + projectErrors: projectErrors.length, + }, + }); + return totals; } @@ -1587,6 +1661,16 @@ export async function rollbackStorage(options: RollbackOptions = {}): Promise 0) { log(` Warning: projects/${projectId} has ${postMigrationSessions} session(s) created after migration — skipping deletion.`); log(` These sessions exist only in projects/${projectId}/ and would be lost. Remove manually after verifying.`); + recordActivityEvent({ + projectId, + source: "migration", + kind: "migration.rollback_skipped", + level: "warn", + summary: `rollback skipped projects/${projectId} — ${postMigrationSessions} post-migration session(s)`, + data: { + postMigrationSessions, + }, + }); } else { safeToDeleteProjects.add(projectId); } diff --git a/packages/core/src/notification-data.ts b/packages/core/src/notification-data.ts new file mode 100644 index 000000000..59e8d84c2 --- /dev/null +++ b/packages/core/src/notification-data.ts @@ -0,0 +1,391 @@ +import type { + CICheck, + CIStatus, + EventType, + PREnrichmentData, + ReviewDecision, + SessionId, + SessionStatus, +} from "./types.js"; + +export const NOTIFICATION_DATA_SCHEMA_VERSION = 3; + +export interface NotificationPRContext { + url: string; + title: string | null; + number: number; + branch: string; + baseBranch?: string; + owner?: string; + repo?: string; + isDraft?: boolean; +} + +export interface NotificationEventContext { + pr: NotificationPRContext | null; + issueId: string | null; + issueTitle: string | null; + summary: string | null; + branch: string | null; +} + +export interface NotificationSessionSubject { + id: SessionId; + projectId: string; +} + +export interface NotificationPRSubject { + number: number; + url: string; + branch?: string; + title?: string; + baseBranch?: string; + owner?: string; + repo?: string; + isDraft?: boolean; +} + +export interface NotificationIssueSubject { + id: string; + title?: string; +} + +export interface NotificationSubject { + session: NotificationSessionSubject; + pr?: NotificationPRSubject; + issue?: NotificationIssueSubject; + summary?: string; + branch?: string; +} + +export interface NotificationTransition { + kind: "session_status" | "pr_state"; + from: string; + to: string; +} + +export interface NotificationCICheck { + name: string; + status: CICheck["status"]; + conclusion?: string; + url?: string; +} + +export interface NotificationCI { + status: CIStatus; + failedChecks?: NotificationCICheck[]; +} + +export interface NotificationReview { + decision?: ReviewDecision; + unresolvedThreads?: number; + url?: string; +} + +export interface NotificationMerge { + ready?: boolean; + conflicts?: boolean; + baseBranch?: string; + isBehind?: boolean; + blockers?: string[]; +} + +export interface NotificationReaction { + key: string; + action: "notify" | "send-to-agent" | "auto-merge" | "escalated"; +} + +export interface NotificationEscalation { + attempts: number; + cause: "max_retries" | "max_attempts" | "max_duration"; + durationMs?: number; +} + +export interface NotificationDataV3 { + [key: string]: unknown; + schemaVersion: typeof NOTIFICATION_DATA_SCHEMA_VERSION; + semanticType?: string; + subject: NotificationSubject; + transition?: NotificationTransition; + ci?: NotificationCI; + review?: NotificationReview; + merge?: NotificationMerge; + reaction?: NotificationReaction; + escalation?: NotificationEscalation; +} + +export interface NotificationDataBaseInput { + sessionId: SessionId; + projectId: string; + context: NotificationEventContext; + semanticType?: string; +} + +export interface SessionTransitionNotificationInput extends NotificationDataBaseInput { + eventType: EventType; + oldStatus: SessionStatus; + newStatus: SessionStatus; + enrichment?: PREnrichmentData; +} + +export interface PRStateNotificationInput extends NotificationDataBaseInput { + eventType: EventType; + oldPRState: string; + newPRState: string; + enrichment?: PREnrichmentData; +} + +export interface CIFailureNotificationInput extends NotificationDataBaseInput { + failedChecks: CICheck[]; +} + +export interface ReactionNotificationInput extends NotificationDataBaseInput { + eventType: "reaction.triggered" | "reaction.escalated"; + reactionKey: string; + action: NotificationReaction["action"]; + enrichment?: PREnrichmentData; +} + +export interface ReactionEscalationNotificationInput extends ReactionNotificationInput { + attempts: number; + cause: NotificationEscalation["cause"]; + durationMs?: number; +} + +const REACTION_SEMANTIC_TYPES: Record = { + "pr-closed": "pr.closed", + "ci-failed": "ci.failing", + "changes-requested": "review.changes_requested", + "bugbot-comments": "automated_review.found", + "merge-conflicts": "merge.conflicts", + "approved-and-green": "merge.ready", + "agent-stuck": "session.stuck", + "agent-needs-input": "session.needs_input", + "agent-exited": "session.killed", + "all-complete": "summary.all_complete", + "report-no-acknowledge": "report.no_acknowledge", + "report-stale": "report.stale", + "report-needs-input": "report.needs_input", +}; + +function maybeString(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function serializeCheck(check: CICheck): NotificationCICheck { + const conclusion = maybeString(check.conclusion); + const url = maybeString(check.url); + return { + name: check.name, + status: check.status, + ...(conclusion ? { conclusion } : {}), + ...(url ? { url } : {}), + }; +} + +export function semanticTypeForReactionKey( + reactionKey: string, + fallback: EventType | string, +): string { + return REACTION_SEMANTIC_TYPES[reactionKey] ?? fallback; +} + +export function buildNotificationSubject(input: NotificationDataBaseInput): NotificationSubject { + const { context, sessionId, projectId } = input; + const subject: NotificationSubject = { + session: { id: sessionId, projectId }, + }; + + if (context.pr) { + const branch = maybeString(context.pr.branch); + const title = maybeString(context.pr.title); + const baseBranch = maybeString(context.pr.baseBranch); + const owner = maybeString(context.pr.owner); + const repo = maybeString(context.pr.repo); + + subject.pr = { + number: context.pr.number, + url: context.pr.url, + ...(branch ? { branch } : {}), + ...(title ? { title } : {}), + ...(baseBranch ? { baseBranch } : {}), + ...(owner ? { owner } : {}), + ...(repo ? { repo } : {}), + ...(typeof context.pr.isDraft === "boolean" ? { isDraft: context.pr.isDraft } : {}), + }; + } + + const issueId = maybeString(context.issueId); + if (issueId) { + const issueTitle = maybeString(context.issueTitle); + subject.issue = { + id: issueId, + ...(issueTitle ? { title: issueTitle } : {}), + }; + } + + const summary = maybeString(context.summary); + if (summary) subject.summary = summary; + const contextBranch = maybeString(context.branch); + if (contextBranch) subject.branch = contextBranch; + + return subject; +} + +function createBaseNotificationData(input: NotificationDataBaseInput): NotificationDataV3 { + return { + schemaVersion: NOTIFICATION_DATA_SCHEMA_VERSION, + ...(maybeString(input.semanticType) ? { semanticType: input.semanticType } : {}), + subject: buildNotificationSubject(input), + }; +} + +function addEnrichment(data: NotificationDataV3, enrichment: PREnrichmentData | undefined): void { + if (!enrichment) return; + + if (enrichment.ciStatus !== "none") { + data.ci = { ...(data.ci ?? {}), status: data.ci?.status ?? enrichment.ciStatus }; + } + + if (enrichment.reviewDecision !== "none") { + data.review = { + ...(data.review ?? {}), + decision: data.review?.decision ?? enrichment.reviewDecision, + }; + } + + if ( + enrichment.mergeable || + typeof enrichment.hasConflicts === "boolean" || + typeof enrichment.isBehind === "boolean" || + (enrichment.blockers?.length ?? 0) > 0 + ) { + data.merge = { + ...(data.merge ?? {}), + ready: data.merge?.ready ?? enrichment.mergeable, + ...(typeof enrichment.hasConflicts === "boolean" + ? { conflicts: data.merge?.conflicts ?? enrichment.hasConflicts } + : {}), + ...(typeof enrichment.isBehind === "boolean" ? { isBehind: enrichment.isBehind } : {}), + ...(enrichment.blockers && enrichment.blockers.length > 0 + ? { blockers: enrichment.blockers } + : {}), + }; + } +} + +function addSemanticDomain( + data: NotificationDataV3, + semanticType: string, + enrichment: PREnrichmentData | undefined, +): void { + switch (semanticType) { + case "ci.failing": + data.ci = { ...(data.ci ?? {}), status: "failing" }; + break; + case "review.pending": + data.review = { ...(data.review ?? {}), decision: "pending" }; + break; + case "review.approved": + data.review = { ...(data.review ?? {}), decision: "approved" }; + break; + case "review.changes_requested": + data.review = { ...(data.review ?? {}), decision: "changes_requested" }; + break; + case "merge.ready": + data.merge = { + ...(data.merge ?? {}), + ready: true, + conflicts: enrichment?.hasConflicts ?? false, + ...(data.subject.pr?.baseBranch ? { baseBranch: data.subject.pr.baseBranch } : {}), + }; + break; + case "merge.conflicts": + data.merge = { + ...(data.merge ?? {}), + ready: false, + conflicts: true, + ...(data.subject.pr?.baseBranch ? { baseBranch: data.subject.pr.baseBranch } : {}), + }; + break; + default: + break; + } +} + +export function buildSessionTransitionNotificationData( + input: SessionTransitionNotificationInput, +): NotificationDataV3 { + const data = createBaseNotificationData({ ...input, semanticType: input.eventType }); + data.transition = { + kind: "session_status", + from: input.oldStatus, + to: input.newStatus, + }; + addEnrichment(data, input.enrichment); + addSemanticDomain(data, input.eventType, input.enrichment); + return data; +} + +export function buildPRStateNotificationData(input: PRStateNotificationInput): NotificationDataV3 { + const data = createBaseNotificationData({ ...input, semanticType: input.eventType }); + data.transition = { + kind: "pr_state", + from: input.oldPRState, + to: input.newPRState, + }; + addEnrichment(data, input.enrichment); + addSemanticDomain(data, input.eventType, input.enrichment); + return data; +} + +export function buildCIFailureNotificationData( + input: CIFailureNotificationInput, +): NotificationDataV3 { + const data = createBaseNotificationData({ ...input, semanticType: "ci.failing" }); + data.ci = { + status: "failing", + failedChecks: input.failedChecks.map(serializeCheck), + }; + return data; +} + +export function buildReactionNotificationData( + input: ReactionNotificationInput, +): NotificationDataV3 { + const semanticType = semanticTypeForReactionKey(input.reactionKey, input.eventType); + const data = createBaseNotificationData({ ...input, semanticType }); + data.reaction = { + key: input.reactionKey, + action: input.action, + }; + addEnrichment(data, input.enrichment); + addSemanticDomain(data, semanticType, input.enrichment); + return data; +} + +export function buildReactionEscalationNotificationData( + input: ReactionEscalationNotificationInput, +): NotificationDataV3 { + const data = buildReactionNotificationData(input); + data.escalation = { + attempts: input.attempts, + cause: input.cause, + ...(typeof input.durationMs === "number" ? { durationMs: input.durationMs } : {}), + }; + return data; +} + +export function getNotificationDataV3(data: unknown): NotificationDataV3 | null { + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const candidate = data as Partial; + if (candidate.schemaVersion !== NOTIFICATION_DATA_SCHEMA_VERSION) return null; + if ( + !candidate.subject || + typeof candidate.subject !== "object" || + Array.isArray(candidate.subject) + ) { + return null; + } + return candidate as NotificationDataV3; +} diff --git a/packages/core/src/notification-observability.ts b/packages/core/src/notification-observability.ts new file mode 100644 index 000000000..8601b895a --- /dev/null +++ b/packages/core/src/notification-observability.ts @@ -0,0 +1,184 @@ +import { recordActivityEvent, type ActivityEventKind } from "./activity-events.js"; +import { createCorrelationId, type ProjectObserver } from "./observability.js"; +import type { OrchestratorEvent } from "./types.js"; + +export type NotificationDeliveryMethod = "notify" | "notifyWithActions"; +export type NotificationDeliveryFailureKind = "delivery_failed" | "target_missing"; + +export interface NotificationDeliveryTarget { + reference: string; + pluginName: string; +} + +export interface RecordNotificationDeliveryInput { + observer: ProjectObserver; + event: OrchestratorEvent; + target: NotificationDeliveryTarget; + outcome: "success" | "failure"; + method?: NotificationDeliveryMethod; + reason?: string; + failureKind?: NotificationDeliveryFailureKind; + recordActivityEvent?: boolean; +} + +const TOKEN_PATTERNS: ReadonlyArray = [ + [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer [redacted]"], + [/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[redacted]"], + [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "[redacted]"], + [/\bsk-(?:ant-)?(?:proj-|svcacct-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted]"], + [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "[redacted]"], + [/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]"], + [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"], + [ + /\b([A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|AUTHORIZATION|COOKIE|API_KEY|APIKEY)[A-Z0-9_]*)=([^\s"'`]{6,})/g, + "$1=[redacted]", + ], +]; + +function redactCredentialUrls(input: string): string { + let result = input; + let offset = 0; + while (offset < result.length) { + const proto = result.indexOf("://", offset); + if (proto === -1) break; + 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; + continue; + } + + let cursor = proto + 3; + let redacted = false; + while (cursor < result.length) { + const ch = result.charCodeAt(cursor); + if (ch <= 0x20 || ch === 0x2f) break; + if (ch === 0x40) { + result = result.slice(0, proto + 3).toLowerCase() + "[redacted]" + result.slice(cursor); + offset = proto + 3 + "[redacted]".length + 1; + redacted = true; + break; + } + cursor++; + } + if (!redacted) offset = proto + 3; + } + return result; +} + +export function sanitizeNotificationDeliveryReason(reason: string): string { + let cleaned = redactCredentialUrls(reason.replace(/\s+/g, " ").trim()); + for (const [pattern, replacement] of TOKEN_PATTERNS) { + cleaned = cleaned.replace(pattern, replacement); + } + return cleaned.length > 300 ? `${cleaned.slice(0, 297)}...` : cleaned; +} + +function notificationSurface(reference: string): string { + let suffix = ""; + let lastNonHyphenLength = 0; + let previousWasGeneratedHyphen = false; + + for (const ch of reference) { + const code = ch.charCodeAt(0); + const isAlphaNumeric = + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a); + const isAllowed = isAlphaNumeric || ch === "_" || ch === "-"; + + if (!isAllowed) { + if (suffix.length > 0 && !previousWasGeneratedHyphen) { + suffix += "-"; + previousWasGeneratedHyphen = true; + } + continue; + } + + if (ch === "-" && suffix.length === 0) { + continue; + } + + suffix += ch; + previousWasGeneratedHyphen = false; + if (ch !== "-") { + lastNonHyphenLength = suffix.length; + } + } + + suffix = suffix.slice(0, lastNonHyphenLength); + return `notification.delivery.${suffix || "target"}`; +} + +function activityKind(kind: NotificationDeliveryFailureKind): ActivityEventKind { + return kind === "target_missing" ? "notification.target_missing" : "notification.delivery_failed"; +} + +function safeRecordActivityFailure(input: RecordNotificationDeliveryInput, reason: string): void { + if (!input.failureKind) return; + try { + recordActivityEvent({ + projectId: input.event.projectId, + sessionId: input.event.sessionId, + source: "notifier", + kind: activityKind(input.failureKind), + level: "warn", + summary: + input.failureKind === "target_missing" + ? `notification target missing: ${input.target.reference}` + : `notification delivery failed: ${input.target.reference}`, + data: { + eventId: input.event.id, + eventType: input.event.type, + priority: input.event.priority, + targetReference: input.target.reference, + targetPlugin: input.target.pluginName, + deliveryMethod: input.method ?? "notify", + errorMessage: reason, + }, + }); + } catch { + // Activity events are diagnostic-only; notification delivery must not depend on them. + } +} + +export function recordNotificationDelivery(input: RecordNotificationDeliveryInput): void { + const reason = input.reason ? sanitizeNotificationDeliveryReason(input.reason) : undefined; + const data = { + eventId: input.event.id, + eventType: input.event.type, + priority: input.event.priority, + targetReference: input.target.reference, + targetPlugin: input.target.pluginName, + deliveryMethod: input.method ?? "notify", + }; + + input.observer.recordOperation({ + metric: "notification_delivery", + operation: "notification.deliver", + outcome: input.outcome, + correlationId: createCorrelationId("notification"), + projectId: input.event.projectId, + sessionId: input.event.sessionId, + reason, + data, + level: input.outcome === "failure" ? "warn" : "info", + }); + + input.observer.setHealth({ + surface: notificationSurface(input.target.reference), + status: input.outcome === "failure" ? "warn" : "ok", + projectId: input.event.projectId, + correlationId: createCorrelationId("notification-health"), + reason, + details: data, + }); + + if (input.outcome === "failure" && input.recordActivityEvent) { + safeRecordActivityFailure(input, reason ?? "notification delivery failed"); + } +} diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 15f62efdd..3c9f4eb4f 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -24,6 +24,7 @@ export type ObservabilityMetricName = | "graphql_batch" | "kill" | "lifecycle_poll" + | "notification_delivery" | "restore" | "send" | "spawn" @@ -167,25 +168,43 @@ function sanitizeComponent(component: string): string { return component.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "component"; } -function getLogLevel(): ObservabilityLevel { - const raw = process.env["AO_LOG_LEVEL"]?.trim().toLowerCase(); - if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") { - return raw; +function parseLogLevel(raw: string | undefined): ObservabilityLevel | null { + const value = raw?.trim().toLowerCase(); + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; } - return "warn"; + return null; } -function shouldLog(level: ObservabilityLevel): boolean { - return LEVEL_ORDER[level] >= LEVEL_ORDER[getLogLevel()]; +function parseBooleanEnv(raw: string | undefined): boolean | null { + const value = raw?.trim().toLowerCase(); + if (!value) return null; + if (value === "1" || value === "true" || value === "yes" || value === "on") return true; + if (value === "0" || value === "false" || value === "no" || value === "off") return false; + return null; } -function shouldMirrorStructuredLogsToStderr(): boolean { - const raw = process.env["AO_OBSERVABILITY_STDERR"]?.trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; +function getLogLevel(config: OrchestratorConfig): ObservabilityLevel { + const raw = process.env["AO_LOG_LEVEL"]?.trim().toLowerCase(); + return parseLogLevel(raw) ?? config.observability?.logLevel ?? "warn"; } -function emitStructuredLog(entry: Record, level: ObservabilityLevel): void { - if (!shouldMirrorStructuredLogsToStderr() || !shouldLog(level)) return; +function shouldLog(level: ObservabilityLevel, config: OrchestratorConfig): boolean { + return LEVEL_ORDER[level] >= LEVEL_ORDER[getLogLevel(config)]; +} + +function shouldMirrorStructuredLogsToStderr(config: OrchestratorConfig): boolean { + return ( + parseBooleanEnv(process.env["AO_OBSERVABILITY_STDERR"]) ?? config.observability?.stderr ?? false + ); +} + +function emitStructuredLog( + config: OrchestratorConfig, + entry: Record, + level: ObservabilityLevel, +): void { + if (!shouldMirrorStructuredLogsToStderr(config) || !shouldLog(level, config)) return; process.stderr.write(`${JSON.stringify({ ...entry, level })}\n`); } @@ -424,9 +443,9 @@ export function createProjectObserver( const snapshot = readSnapshot(filePath, normalizedComponent); updater(snapshot); writeSnapshot(config, snapshot); - if (logEntry && shouldLog(logEntry.level)) { + if (logEntry && shouldLog(logEntry.level, config)) { appendAuditLog(config, normalizedComponent, logEntry.payload, logEntry.level); - emitStructuredLog(logEntry.payload, logEntry.level); + emitStructuredLog(config, logEntry.payload, logEntry.level); } } catch (error) { const payload = { @@ -438,7 +457,7 @@ export function createProjectObserver( reason: error instanceof Error ? error.message : String(error), }; appendObservabilityFailure(config, payload); - emitStructuredLog(payload, "error"); + emitStructuredLog(config, payload, "error"); } } diff --git a/packages/core/src/paths.ts b/packages/core/src/paths.ts index 1485eeadc..40fdc6ce7 100644 --- a/packages/core/src/paths.ts +++ b/packages/core/src/paths.ts @@ -125,6 +125,11 @@ export function getProjectWorktreesDir(projectId: string): string { return join(getProjectDir(projectId), "worktrees"); } +/** Get the AO-local code review store directory for a project. */ +export function getProjectCodeReviewsDir(projectId: string): string { + return join(getProjectDir(projectId), "code-reviews"); +} + /** Get the feedback reports directory for a project (V2 layout). */ export function getProjectFeedbackReportsDir(projectId: string): string { return join(getProjectDir(projectId), "feedback-reports"); diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index d67a0d32d..ed8db891a 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -19,6 +19,7 @@ import type { PluginRegistry, OrchestratorConfig, } from "./types.js"; +import { recordActivityEvent } from "./activity-events.js"; /** Map from "slot:name" → plugin instance */ type PluginMap = Map; @@ -58,6 +59,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "scm", name: "gitlab", pkg: "@aoagents/ao-plugin-scm-gitlab" }, // Notifiers { slot: "notifier", name: "composio", pkg: "@aoagents/ao-plugin-notifier-composio" }, + { slot: "notifier", name: "dashboard", pkg: "@aoagents/ao-plugin-notifier-dashboard" }, { slot: "notifier", name: "desktop", pkg: "@aoagents/ao-plugin-notifier-desktop" }, { slot: "notifier", name: "discord", pkg: "@aoagents/ao-plugin-notifier-discord" }, { slot: "notifier", name: "openclaw", pkg: "@aoagents/ao-plugin-notifier-openclaw" }, @@ -78,6 +80,19 @@ function matchesNotifierPlugin( return hasExplicitPlugin ? configuredPlugin === pluginName : notifierId === pluginName; } +function hasExplicitConflictingNotifierEntry( + pluginName: string, + config: OrchestratorConfig, +): boolean { + if (!Object.prototype.hasOwnProperty.call(config.notifiers ?? {}, pluginName)) return false; + const exactMatch = config.notifiers?.[pluginName]; + return ( + !exactMatch || + typeof exactMatch !== "object" || + !matchesNotifierPlugin(pluginName, pluginName, exactMatch) + ); +} + function collectNotifierRegistrations( pluginName: string, config: OrchestratorConfig, @@ -85,6 +100,14 @@ function collectNotifierRegistrations( ): NotifierRegistration[] { const orderedMatches = new Map>(); const notifierEntries = Object.entries(config.notifiers ?? {}); + const defaultNotifiers = Array.isArray(config.defaults?.notifiers) + ? config.defaults.notifiers + : []; + const routingNotifiers = Object.values(config.notificationRouting ?? {}).flatMap((value) => + Array.isArray(value) ? value : [], + ); + const isReferencedByName = + defaultNotifiers.includes(pluginName) || routingNotifiers.includes(pluginName); const exactMatch = config.notifiers?.[pluginName]; if ( @@ -102,6 +125,14 @@ function collectNotifierRegistrations( } } + if ( + orderedMatches.size === 0 && + isReferencedByName && + !hasExplicitConflictingNotifierEntry(pluginName, config) + ) { + orderedMatches.set(pluginName, {}); + } + return [...orderedMatches.entries()].map(([registrationName, rawConfig]) => ({ registrationName, config: prepareConfig( @@ -142,9 +173,12 @@ function prepareConfig( // Skip the built-in guard for external loads: when loading via `path`, the manifest.name // may legitimately collide with a built-in (e.g. a forked "slack"), and the path field // here IS the loading path, not a stray user config value. - const isBuiltin = !isExternalLoad && BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name); + const isBuiltin = + !isExternalLoad && BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name); if ((rawConfig.package || isBuiltin) && "path" in rawConfig) { - const loadingMethod = rawConfig.package ? `npm package "${rawConfig.package}"` : `built-in plugin "${name}"`; + const loadingMethod = rawConfig.package + ? `npm package "${rawConfig.package}"` + : `built-in plugin "${name}"`; throw new Error( `In ${slot} "${sourceId}": "path" field conflicts with reserved plugin loading field. ` + `You're loading via ${loadingMethod}, but also have a "path" field which would be stripped. ` + @@ -403,6 +437,7 @@ export function createPluginRegistry(): PluginRegistry { const registrations = collectNotifierRegistrations(manifest.name, config, isExternalLoad); if (registrations.length === 0) { + if (hasExplicitConflictingNotifierEntry(manifest.name, config)) return; registerInstance(manifest.slot, manifest.name, manifest, plugin.create(undefined)); return; } @@ -461,9 +496,23 @@ export function createPluginRegistry(): PluginRegistry { this.register(mod); } } catch (error) { + const message = error instanceof Error ? error.message : String(error); process.stderr.write( `[plugin-registry] Failed to load built-in plugin "${builtin.name}": ${error}\n`, ); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.load_failed", + level: "error", + summary: `built-in plugin ${builtin.name} failed to load`, + data: { + plugin: builtin.name, + slot: builtin.slot, + pkg: builtin.pkg, + builtin: true, + error: message, + }, + }); } } } @@ -485,7 +534,19 @@ export function createPluginRegistry(): PluginRegistry { const specifier = resolvePluginSpecifier(plugin, config); if (!specifier) { - process.stderr.write(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`); + process.stderr.write( + `[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`, + ); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.specifier_failed", + level: "error", + summary: `could not resolve specifier for plugin ${plugin.name}`, + data: { + plugin: plugin.name, + source: plugin.source ?? null, + }, + }); continue; } @@ -508,9 +569,27 @@ export function createPluginRegistry(): PluginRegistry { // Log validation errors but don't abort - other projects can still use the plugin. // The misconfigured project will fail later when it tries to use the plugin // with the wrong name, giving a clearer error at point of use. + const message = + validationError instanceof Error + ? validationError.message + : String(validationError); process.stderr.write( `[plugin-registry] Config validation failed for ${externalEntry.source}: ${validationError}\n`, ); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.validation_failed", + level: "error", + summary: `plugin manifest validation failed for ${plugin.name}`, + data: { + plugin: plugin.name, + externalSource: externalEntry.source, + specifier, + manifestName: mod.manifest.name, + manifestSlot: mod.manifest.slot, + error: message, + }, + }); } } @@ -520,7 +599,23 @@ export function createPluginRegistry(): PluginRegistry { this.register(mod); } } catch (error) { - process.stderr.write(`[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`); + const message = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`, + ); + recordActivityEvent({ + source: "plugin-registry", + kind: "plugin-registry.load_failed", + level: "error", + summary: `external plugin ${plugin.name} failed to load`, + data: { + plugin: plugin.name, + specifier, + source: plugin.source ?? null, + builtin: false, + error: message, + }, + }); } } }, diff --git a/packages/core/src/portfolio-session-service.ts b/packages/core/src/portfolio-session-service.ts index 0d1ce5472..4be551226 100644 --- a/packages/core/src/portfolio-session-service.ts +++ b/packages/core/src/portfolio-session-service.ts @@ -141,6 +141,7 @@ function metadataToSession(sessionId: string, project: PortfolioProject, metadat return sessionFromMetadata(sessionId, metadataToRecord(metadata), { projectId: project.id, + workspacePathFallback: project.repoPath, status: (metadata.status as Session["status"]) || "spawning", activity: null, runtimeHandle: metadata.runtimeHandle ?? null, diff --git a/packages/core/src/project-resolver.ts b/packages/core/src/project-resolver.ts index bd98d9db3..fd8311f25 100644 --- a/packages/core/src/project-resolver.ts +++ b/packages/core/src/project-resolver.ts @@ -16,7 +16,7 @@ export function loadEffectiveProjectConfig( throw new ProjectResolveError(projectId, `Unknown project: ${projectId}`); } if (typeof resolved.resolveError === "string" && resolved.resolveError.length > 0) { - throw new ProjectResolveError(projectId, resolved.resolveError); + throw new ProjectResolveError(projectId, resolved.resolveError, resolved.resolveErrorKind); } return resolved; } diff --git a/packages/core/src/prompts/orchestrator.md b/packages/core/src/prompts/orchestrator.md index 08ca7ec02..2551bc287 100644 --- a/packages/core/src/prompts/orchestrator.md +++ b/packages/core/src/prompts/orchestrator.md @@ -39,6 +39,12 @@ ao spawn --prompt "Refactor the auth module to use JWT" # List sessions ao session ls -p {{projectId}} +# List AO-local reviewer runs +ao review list {{projectId}} + +# Send completed AO-local review findings back to the linked coding worker +ao review send {{projectSessionPrefix}}-rev-1 -p {{projectId}} + # Send message to a session ao send {{projectSessionPrefix}}-1 "Your message here" @@ -64,6 +70,10 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}} - `ao spawn [issue] [--prompt ]{{REPO_CONFIGURED_SECTION_START}} [--claim-pr ]{{REPO_CONFIGURED_SECTION_END}}`: Spawn a worker session{{REPO_CONFIGURED_SECTION_START}}; use issue ID or --prompt for freeform tasks{{REPO_CONFIGURED_SECTION_END}}{{REPO_NOT_CONFIGURED_SECTION_START}} with --prompt for freeform tasks{{REPO_NOT_CONFIGURED_SECTION_END}} {{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn `: Spawn multiple sessions in parallel (project auto-detected) {{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project) +- `ao review list [project]`: List AO-local reviewer runs. These are review agents/runs, not coding worker sessions. +- `ao review run [--execute]`: Request a reviewer run for a coding worker session. +- `ao review execute [project] [--run ]`: Execute a queued reviewer run. +- `ao review send [-p project]`: Send open AO-local findings from a completed reviewer run to its linked coding worker, then mark the run as waiting for worker updates. {{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr [session]`: Attach an existing PR to a worker session {{REPO_CONFIGURED_SECTION_END}}- `ao session attach `: Attach to a session's terminal (a tmux window on Unix; a ConPTY pty-host on Windows) - `ao session kill `: Kill a specific session @@ -96,6 +106,7 @@ ao spawn --prompt "Add rate limiting to the /api/upload endpoint" Use `ao status` to see: - Current session status (working, pr_open, review_pending, etc.) +- AO-local reviewer run summary and open finding counts {{REPO_CONFIGURED_SECTION_START}}- PR state (open/merged/closed) - CI status (passing/failing/pending) - Review decision (approved/changes_requested/pending) @@ -111,6 +122,24 @@ ao status --reports full # full audit trail per session Reach for this when an inferred status disagrees with what the worker said, when deciding whether to send a follow-up instruction vs. wait, or when triaging a session that looks stuck. +Reviewer runs are intentionally separate from coding worker sessions. A reviewer run has its own workspace and context, and does not appear in `ao session ls` as a coding session. Use `ao status` for the summary and `ao review list {{projectId}}` for the detailed reviewer-run list. + +When a reviewer run has open findings, do not manually summarize them from memory. Use `ao review send -p {{projectId}}` to hand the stored findings back to the linked coding worker through AO. After sending, monitor the worker and request a new review once it reports the fixes are ready. + +### AO-Local Review Loop + +When the user asks you to review a worker, review a PR, or keep reviewing until clean, handle the loop internally: + +1. Inspect current state with `ao status` and identify the coding worker session. +2. Request and execute the reviewer run with `ao review run --execute`. +3. If the run is clean, report that the work is AO-review clean. +4. If the run has open findings, send the stored findings to the linked coding worker with `ao review send -p {{projectId}}`. +5. Monitor the coding worker with `ao status` and wait for it to push fixes or report `ready-for-review`. +6. Re-run `ao review run --execute` after the worker updates. +7. Continue until the review is clean, the worker is stuck, the user asks you to stop, or the configured review round limit is reached. + +Do not ask the user to manually run review commands for routine review/fix iterations. Treat review commands as orchestration internals, the same way worker spawning and `ao send` are orchestration internals. + ### Explicit Agent Reports Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report ` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, pr-created, draft-pr-created, ready-for-review, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth. diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index 14e315b08..f7b57980a 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -5,6 +5,7 @@ import type { Runtime, Workspace, } from "../types.js"; +import { recordActivityEvent } from "../activity-events.js"; import { updateMetadata } from "../metadata.js"; import { getProjectSessionsDir } from "../paths.js"; import { validateStatus } from "../utils/validation.js"; @@ -132,6 +133,7 @@ export async function recoverSession( const session = sessionFromMetadata(sessionId, updatedMetadata, { projectId: assessment.projectId, + workspacePathFallback: assessment.workspacePath ?? undefined, status: preservedStatus, runtimeHandle: assessment.runtimeHandle, lastActivityAt: new Date(), @@ -145,11 +147,25 @@ export async function recoverSession( session, }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + recordActivityEvent({ + projectId, + sessionId, + source: "recovery", + kind: "recovery.action_failed", + level: "error", + summary: `recoverSession threw for ${sessionId}: ${errorMessage}`, + data: { + action: "recover", + recoveryCount, + errorMessage, + }, + }); return { success: false, sessionId, action: "recover", - error: error instanceof Error ? error.message : String(error), + error: errorMessage, }; } } diff --git a/packages/core/src/recovery/manager.ts b/packages/core/src/recovery/manager.ts index 1c90e007f..330922e7f 100644 --- a/packages/core/src/recovery/manager.ts +++ b/packages/core/src/recovery/manager.ts @@ -1,4 +1,5 @@ import type { OrchestratorConfig, PluginRegistry, Session } from "../types.js"; +import { recordActivityEvent } from "../activity-events.js"; import { scanAllSessions, getRecoveryLogPath } from "./scanner.js"; import { validateSession } from "./validator.js"; import { executeAction } from "./actions.js"; @@ -106,9 +107,10 @@ export async function runRecovery(options: RecoveryManagerOptions): Promise, - projectId: string, - sessionPrefix?: string, - createdAt?: Date, - modifiedAt?: Date, + options: MetadataToSessionOptions, ): Session { const sessionKind = meta["role"] === "orchestrator" || - (sessionPrefix - ? new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) + (options.sessionPrefix + ? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) : false) ? "orchestrator" : "worker"; return sessionFromMetadata(sessionId, meta, { - projectId, + projectId: options.projectId, + workspacePathFallback: options.workspacePathFallback, sessionKind, - createdAt, - lastActivityAt: modifiedAt ?? new Date(), + createdAt: options.createdAt, + lastActivityAt: options.modifiedAt ?? new Date(), }); } @@ -453,18 +459,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix); } - function requiresNativeRestore(agentName: string): boolean { - // kimicode is intentionally excluded: kimi's session dir only exists if the - // previous launch ran far enough to write to ~/.kimi/sessions. A failed - // launch (e.g. the --agent-file YAML crash) leaves no session to resume, - // and falling back to a fresh getLaunchCommand is the only sensible choice. - return ( - agentName === "claude-code" || - agentName === "codex" || - agentName === "opencode" - ); - } - function applyMetadataUpdatesToRaw( raw: Record, updates: Partial>, @@ -538,6 +532,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM expiresAt: number; } | null = null; const ensureOrchestratorPromises = new Map>(); + const relaunchOrchestratorPromises = new Map>(); function invalidateCache(): void { sessionCache = null; @@ -1019,12 +1014,68 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM */ const TERMINAL_SESSION_STATUSES = new Set(["killed", "done", "merged", "terminated", "cleanup"]); + function hasPersistedNativeRestoreMetadata(session: Session, agent: Agent): boolean { + const metadata = session.metadata ?? {}; + + switch (agent.name) { + case "claude-code": + return typeof metadata["claudeSessionUuid"] === "string" && metadata["claudeSessionUuid"].trim().length > 0; + case "codex": + return typeof metadata["codexThreadId"] === "string" && metadata["codexThreadId"].trim().length > 0; + case "opencode": + return asValidOpenCodeSessionId(metadata["opencodeSessionId"]) !== null; + default: + return false; + } + } + + function canDiscoverSessionInfoAfterRuntimeExit(agent: Agent): boolean { + return agent.name === "claude-code" || agent.name === "codex"; + } + async function enrichSessionWithRuntimeState( session: Session, plugins: ReturnType, handleFromMetadata: boolean, sessionsDir: string, ): Promise { + async function persistAgentSessionInfo(options?: { skipIfNativeRestoreMetadataPresent?: boolean }): Promise { + if (!plugins.agent) return; + if ( + options?.skipIfNativeRestoreMetadataPresent && + hasPersistedNativeRestoreMetadata(session, plugins.agent) + ) { + return; + } + + let info: Awaited>; + try { + info = await plugins.agent.getSessionInfo(session); + } catch { + // Can't get session info — keep existing values + info = null; + } + + if (!info) return; + + session.agentInfo = info; + const metadataUpdates = info.metadata ?? {}; + const allAlreadyPersisted = Object.keys(metadataUpdates).every( + (key) => session.metadata?.[key] === metadataUpdates[key], + ); + if (allAlreadyPersisted) return; + + if (Object.keys(metadataUpdates).length > 0) { + try { + updateMetadata(sessionsDir, session.id, metadataUpdates); + session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates); + invalidateCache(); + } catch { + // Persisting agent metadata is best-effort; keep live agent info. + } + } + } + // Check runtime liveness first — for all statuses except "spawning". // Skip spawning sessions because tmux may not be fully initialized yet, // and a false-negative from isAlive() would permanently mark the session @@ -1065,6 +1116,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM activity: "exited", source: "runtime", }); + // Dead-runtime session info discovery is intentionally limited to + // agents that recover restore metadata from persisted local files. + if (plugins.agent && canDiscoverSessionInfoAfterRuntimeExit(plugins.agent)) { + await persistAgentSessionInfo({ skipIfNativeRestoreMetadataPresent: true }); + } return; } } catch { @@ -1101,28 +1157,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM session.activitySignal = createActivitySignal("probe_failure", { source: "native" }); } - // Enrich with live agent session info (summary, cost). - let info: Awaited>; - try { - info = await plugins.agent.getSessionInfo(session); - } catch { - // Can't get session info — keep existing values - info = null; - } - - if (info) { - session.agentInfo = info; - const metadataUpdates = info.metadata ?? {}; - if (Object.keys(metadataUpdates).length > 0) { - try { - updateMetadata(sessionsDir, session.id, metadataUpdates); - session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates); - invalidateCache(); - } catch { - // Persisting agent metadata is best-effort; keep live agent info. - } - } - } + // Enrich with agent session info (summary, cost, native restore metadata). + await persistAgentSessionInfo(); } } @@ -1185,6 +1221,18 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Branch will be generated as feat/{issueId} (line 329-331) } else { // Other error (auth, network, etc) - fail fast + recordActivityEvent({ + projectId: spawnConfig.projectId, + source: "session-manager", + kind: "tracker.issue_fetch_failed", + level: "error", + summary: `tracker getIssue failed for ${spawnConfig.issueId}`, + data: { + issueId: spawnConfig.issueId, + tracker: plugins.tracker.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); throw new Error(`Failed to fetch issue ${spawnConfig.issueId}: ${err}`, { cause: err }); } } @@ -1199,10 +1247,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // step now requires pushing one cleanup, with no risk of forgetting prior // ones. const cleanupStack = new CleanupStack(); + let sessionId: string | undefined; try { // Determine session ID — atomically reserve to prevent concurrent collisions - const { sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir); - cleanupStack.push(() => deleteMetadata(sessionsDir, sessionId)); + let tmuxName: string | undefined; + ({ sessionId, tmuxName } = await reserveNextSessionIdentity(project, sessionsDir)); + const reservedSessionId = sessionId; + cleanupStack.push(() => deleteMetadata(sessionsDir, reservedSessionId)); // Determine branch name — explicit branch always takes priority let branch: string; @@ -1259,9 +1310,23 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (spawnConfig.issueId && plugins.tracker && resolvedIssue) { try { issueContext = await plugins.tracker.generatePrompt(spawnConfig.issueId, project); - } catch { - // Non-fatal: continue without detailed issue context - // Silently ignore errors - caller can check if issueContext is undefined + } catch (err) { + // Non-fatal: continue without detailed issue context. Surface the + // failure via AE so RCA can answer "did the agent get an enriched + // prompt or just the bare issue ID?" + recordActivityEvent({ + projectId: spawnConfig.projectId, + sessionId, + source: "session-manager", + kind: "tracker.generate_prompt_failed", + level: "warn", + summary: `tracker generatePrompt failed for ${spawnConfig.issueId}`, + data: { + issueId: spawnConfig.issueId, + tracker: plugins.tracker.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } @@ -1478,14 +1543,81 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // Log cleanup failures so they don't disappear silently. The original // code used /* best effort */ swallows; the stack preserves that // behavior (cleanup errors don't propagate) but surfaces them for debug. + recordActivityEvent({ + projectId: spawnConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.rollback_started", + level: "warn", + summary: "spawn rollback started", + data: { reason: err instanceof Error ? err.message : String(err) }, + }); await cleanupStack.runAll((cleanupErr) => { console.error("[session-manager] spawn rollback step failed:", cleanupErr); + // B25: emit per-step rollback failure so each leaked resource is queryable. + recordActivityEvent({ + projectId: spawnConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.rollback_step_failed", + level: "error", + summary: "spawn rollback step failed", + data: { + reason: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + }, + }); }); throw err; } } - async function spawnOrchestrator(orchestratorConfig: OrchestratorSpawnConfig): Promise { + function recordOrchestratorSpawnFailed( + orchestratorConfig: OrchestratorSpawnConfig, + err: unknown, + sessionId?: string, + ): void { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + ...(sessionId ? { sessionId } : {}), + source: "session-manager", + kind: "session.spawn_failed", + level: "error", + summary: "orchestrator spawn failed", + data: { + role: "orchestrator", + reason: err instanceof Error ? err.message : String(err), + }, + }); + } + + async function spawnOrchestrator( + orchestratorConfig: OrchestratorSpawnConfig, + options?: { suppressFixedReservationFailure?: boolean }, + ): Promise { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + source: "session-manager", + kind: "session.spawn_started", + summary: "orchestrator spawn started", + data: { agent: orchestratorConfig.agent ?? undefined, role: "orchestrator" }, + }); + try { + return await _spawnOrchestratorInner(orchestratorConfig); + } catch (err) { + const project = config.projects[orchestratorConfig.projectId]; + const sessionId = project ? getOrchestratorSessionId(project) : undefined; + const shouldSuppressRecoverableConflict = + options?.suppressFixedReservationFailure === true && + sessionId !== undefined && + isFixedOrchestratorReservationError(err, sessionId); + if (!shouldSuppressRecoverableConflict) { + recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId); + } + throw err; + } + } + + async function _spawnOrchestratorInner(orchestratorConfig: OrchestratorSpawnConfig): Promise { const project = config.projects[orchestratorConfig.projectId]; if (!project) { throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); @@ -1546,6 +1678,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM workspacePath = wsInfo.path; adoptedManagedWorkspace = adoptedInfo !== undefined && adoptedInfo !== null; } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator workspace.create failed", + data: { + role: "orchestrator", + stage: "workspace_create", + reason: err instanceof Error ? err.message : String(err), + }, + }); try { deleteMetadata(sessionsDir, sessionId); } catch { @@ -1591,6 +1736,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await setupPathWrapperWorkspace(workspacePath); } } catch (err) { + // PR tracking and CI fetch hooks are wired here — emit a dedicated AE + // before rolling back so RCA can answer "did the orchestrator launch + // succeed but lose its hook integration?". + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.workspace_hooks_failed", + level: "error", + summary: "orchestrator workspace hooks installation failed", + data: { + agent: plugins.agent.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(); throw err; } @@ -1606,6 +1766,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM systemPromptFile = join(projectDir, `orchestrator-prompt-${sessionId}.md`); writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator systemPrompt write failed", + data: { + role: "orchestrator", + stage: "system_prompt_write", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1615,6 +1788,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { writeWorkspaceOpenCodeAgentsMd(workspacePath, systemPromptFile); } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator AGENTS.md write failed", + data: { + role: "orchestrator", + stage: "agents_md_write", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1639,6 +1825,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }); } } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator opencode session resolution failed", + data: { + role: "orchestrator", + stage: "opencode_session_reuse", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1696,6 +1895,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM }, }); } catch (err) { + // Outer envelope catches and emits session.spawn_failed; this step emit + // tags the runtime.create failure path specifically so RCA can answer + // "did the orchestrator runtime fail to start at all?". + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator runtime.create failed", + data: { + role: "orchestrator", + stage: "runtime_create", + reason: err instanceof Error ? err.message : String(err), + }, + }); await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1783,6 +1998,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } invalidateCache(); } catch (err) { + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawn_step_failed", + level: "error", + summary: "orchestrator post-launch metadata write failed", + data: { + role: "orchestrator", + stage: "post_launch_metadata", + reason: err instanceof Error ? err.message : String(err), + }, + }); // Clean up runtime on post-launch failure try { await plugins.runtime.destroy(handle); @@ -1793,6 +2021,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw err; } + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.spawned", + summary: `spawned: ${sessionId}`, + data: { + agent: plugins.agent.name, + branch: session.branch ?? undefined, + role: "orchestrator", + }, + }); + return session; } @@ -1815,6 +2056,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } const sessionId = getOrchestratorSessionId(project); + + // If a relaunch is mid-flight for this sessionId, wait it out — otherwise + // we could return a session that relaunch is about to kill, or race the + // relaunch's spawnOrchestrator on the same reservation. + const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId); + if (pendingRelaunch) { + await pendingRelaunch.catch((err) => { + console.warn( + `[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`, + err, + ); + }); + } + const existing = await get(sessionId); if (existing) { const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( @@ -1847,14 +2102,27 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } try { - return await spawnOrchestrator(orchestratorConfig); + return await spawnOrchestrator(orchestratorConfig, { + suppressFixedReservationFailure: true, + }); } catch (err) { if (!isFixedOrchestratorReservationError(err, sessionId)) { throw err; } + recordActivityEvent({ + projectId: orchestratorConfig.projectId, + sessionId, + source: "session-manager", + kind: "session.orchestrator_conflict", + level: "warn", + summary: "concurrent orchestrator reservation conflict", + data: { reason: err instanceof Error ? err.message : String(err) }, + }); + const concurrent = await waitForConcurrentOrchestrator(sessionId); if (concurrent) return concurrent; + recordOrchestratorSpawnFailed(orchestratorConfig, err, sessionId); throw err; } } @@ -1876,6 +2144,61 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return promise; } + async function relaunchOrchestratorInternal( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId); + + // If ensureOrchestrator is mid-flight for this sessionId, wait it out. + // Otherwise get() would return null (metadata not yet written) and we'd + // skip the kill, then race the in-flight spawnOrchestrator on the same + // reservation — surfacing "session already exists" instead of replacing. + const pendingEnsure = ensureOrchestratorPromises.get(sessionId); + if (pendingEnsure) { + await pendingEnsure.catch((err) => { + console.warn( + `[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`, + err, + ); + }); + } + + const existing = await get(sessionId); + if (existing) { + const existingAgent = resolveSelectionForSession( + project, + sessionId, + readMetadataRaw(sessionsDir, sessionId) ?? {}, + ).agentName; + await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" }); + deleteMetadata(sessionsDir, sessionId); + } + return spawnOrchestrator(orchestratorConfig); + } + + async function relaunchOrchestrator( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const existingPromise = relaunchOrchestratorPromises.get(sessionId); + if (existingPromise) return existingPromise; + + const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => { + relaunchOrchestratorPromises.delete(sessionId); + }); + relaunchOrchestratorPromises.set(sessionId, promise); + return promise; + } + async function list(projectId?: string): Promise { const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => { if (projectId && entryProjectId !== projectId) return []; @@ -1907,10 +2230,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession( sessionName, raw, - sessionProjectId, - project.sessionPrefix, - createdAt, - modifiedAt, + { + projectId: sessionProjectId, + sessionPrefix: project.sessionPrefix, + createdAt, + modifiedAt, + workspacePathFallback: project.path, + }, ); const selection = resolveSelectionForSession(project, sessionName, raw); const effectiveAgentName = selection.agentName; @@ -1941,32 +2267,63 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } - // Persist lifecycle to disk when enrichment detected a dead runtime. - // enrichSessionWithRuntimeState updates the in-memory lifecycle but - // doesn't write to disk — without this, the stale "alive" state persists - // and the dashboard shows terminated sessions on the active sidebar. + // Persist runtime probe result to disk so the lifecycle manager sees it + // on next poll. We only persist the runtime signal and detecting state — + // the lifecycle manager's resolveProbeDecision pipeline is the single + // authority on terminal decisions (terminated/done). See #1735. + // Check the on-disk state (raw) to avoid re-writing when already + // detecting — enrichment sets detecting in-memory, but we only need + // to persist the transition once to avoid resetting lastTransitionAt. + const onDiskLifecycle = parseCanonicalLifecycle(raw, { + sessionId: sessionName, + status: validateStatus(raw["status"]), + }); if ( session.lifecycle && (session.lifecycle.runtime.state === "missing" || session.lifecycle.runtime.state === "exited") && - session.lifecycle.session.state !== "terminated" && - session.lifecycle.session.state !== "done" + onDiskLifecycle.session.state !== "terminated" && + onDiskLifecycle.session.state !== "done" && + onDiskLifecycle.session.state !== "detecting" ) { + const runtimeStateBefore = session.lifecycle.runtime.state; + const runtimeReasonBefore = session.lifecycle.runtime.reason; try { const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => { - next.session.state = "terminated"; + next.session.state = "detecting"; next.session.reason = "runtime_lost"; - next.session.terminatedAt = new Date().toISOString(); - next.session.lastTransitionAt = next.session.terminatedAt; - next.runtime.state = session.lifecycle!.runtime.state; - next.runtime.reason = session.lifecycle!.runtime.reason; + next.session.lastTransitionAt = new Date().toISOString(); + next.runtime.state = runtimeStateBefore; + next.runtime.reason = runtimeReasonBefore; next.runtime.lastObservedAt = new Date().toISOString(); }); + // B1: persist BEFORE emitting the event updateMetadata(sessionsDir, sessionName, lifecycleMetadataUpdates(raw, persisted)); session.lifecycle = persisted; session.status = deriveLegacyStatus(persisted); - } catch { + recordActivityEvent({ + projectId: sessionProjectId, + sessionId: sessionName, + source: "session-manager", + kind: "runtime.lost_detected", + level: "warn", + summary: `runtime lost reconciled: ${sessionName}`, + data: { + runtimeState: runtimeStateBefore, + runtimeReason: runtimeReasonBefore, + }, + }); + } catch (err) { // Persist failed — in-memory state is still correct for this request + recordActivityEvent({ + projectId: sessionProjectId, + sessionId: sessionName, + source: "session-manager", + kind: "runtime.lost_persist_failed", + level: "error", + summary: `runtime_lost persist failed: ${sessionName}`, + data: { reason: err instanceof Error ? err.message : String(err) }, + }); } } @@ -2021,10 +2378,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession( sessionId, repaired.raw, - projectId, - project.sessionPrefix, - createdAt, - modifiedAt, + { + projectId, + sessionPrefix: project.sessionPrefix, + createdAt, + modifiedAt, + workspacePathFallback: project.path, + }, ); const selection = resolveSelectionForSession(project, sessionId, repaired.raw); @@ -2074,6 +2434,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const killReason: LifecycleKillReason = options?.reason ?? "manually_killed"; const cleanupAgent = resolveSelectionForSession(project, sessionId, raw).agentName; + // Emit kill_started up-front — this is the only signal that the kill + // intent reached the manager (the destroys below are silent on failure). + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.kill_started", + summary: `kill started: ${sessionId}`, + data: { reason: killReason }, + }); + // Destroy runtime — prefer handle.runtimeName to find the correct plugin if (raw["runtimeHandle"]) { const handle = safeJsonParse(raw["runtimeHandle"]); @@ -2086,8 +2457,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (runtimePlugin) { try { await runtimePlugin.destroy(handle); - } catch { - // Runtime might already be gone + } catch (err) { + // Runtime might already be gone — surface as AE so leaks are queryable. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "runtime.destroy_failed", + level: "warn", + summary: `runtime.destroy failed during kill: ${sessionId}`, + data: { + runtime: handle.runtimeName ?? null, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -2101,8 +2484,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (workspacePlugin) { try { await workspacePlugin.destroy(worktree); - } catch { - // Workspace might already be gone + } catch (err) { + // Workspace might already be gone — emit AE so abandoned worktrees + // surface for cleanup tooling. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "workspace.destroy_failed", + level: "warn", + summary: `workspace.destroy failed during kill: ${sessionId}`, + data: { + workspace: workspacePlugin.name, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -2120,8 +2516,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { await deleteOpenCodeSession(mappedOpenCodeSessionId); didPurgeOpenCodeSession = true; - } catch { - void 0; + } catch (err) { + // Dangling opencode session is a real leak — surface for RCA. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "agent.opencode_purge_failed", + level: "warn", + summary: `opencode session purge failed: ${sessionId}`, + data: { + opencodeSessionId: mappedOpenCodeSessionId, + reason: err instanceof Error ? err.message : String(err), + }, + }); } } } @@ -2254,9 +2662,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM pushSkipped(session.projectId, session.id); } } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); result.errors.push({ sessionId: session.id, - error: err instanceof Error ? err.message : String(err), + error: errorMessage, + }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "session-manager", + kind: "session.cleanup_error", + level: "warn", + summary: `cleanup error: ${session.id}`, + data: { reason: errorMessage }, }); } } @@ -2298,11 +2716,24 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ...existing, opencodeSessionId: "", opencodeCleanedAt: new Date().toISOString(), - })); + }), { activityEventSource: "session-manager" }); } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); result.errors.push({ sessionId: terminatedId, - error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${err instanceof Error ? err.message : String(err)}`, + error: `Failed to delete OpenCode session ${mappedOpenCodeSessionId}: ${errorMessage}`, + }); + recordActivityEvent({ + projectId: projectKey, + sessionId: terminatedId, + source: "session-manager", + kind: "agent.opencode_purge_failed", + level: "warn", + summary: `opencode session purge failed during cleanup: ${terminatedId}`, + data: { + opencodeSessionId: mappedOpenCodeSessionId, + reason: errorMessage, + }, }); continue; } @@ -2333,7 +2764,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } async function send(sessionId: SessionId, message: string): Promise { - const { raw, sessionsDir, project } = requireSessionRecord(sessionId); + const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); const selection = resolveSelectionForSession(project, sessionId, raw); const selectedAgent = selection.agentName; @@ -2485,19 +2916,31 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Cannot send to session ${sessionId}: ${reason}`); } + let restored: Session; try { - const restored = await restore(sessionId); - const ready = await waitForRestoredSession(restored); - if (!ready) { - throw new Error("restored session did not become ready for delivery"); - } - return restored; + restored = await restore(sessionId); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`, { cause: err, }); } + + const ready = await waitForRestoredSession(restored); + if (!ready) { + const detail = "restored session did not become ready for delivery"; + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore for delivery failed: ${sessionId}`, + data: { stage: "ready_timeout", reason: detail, trigger: "send" }, + }); + throw new Error(`Cannot send to session ${sessionId}: ${reason} (${detail})`); + } + return restored; }; const prepareSession = async (forceRestore = false): Promise => { @@ -2589,29 +3032,52 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return; }; - let prepared = await prepareSession(); - + // Top-level try/catch: any final send failure (initial preparation, + // retry-with-restore, etc.) emits a single `session.send_failed` event + // (B16 — failure-only). Stage tag distinguishes which branch failed. + let stage: "prepare" | "initial" | "restore_retry" = "prepare"; try { - await sendWithConfirmation(prepared); - } catch (err) { - const shouldRetryWithRestore = prepared.restoredAt === undefined && isRestorable(prepared); + let prepared = await prepareSession(); - if (!shouldRetryWithRestore) { - if (err instanceof Error) { - throw err; - } - throw new Error(String(err), { cause: err }); - } - - prepared = await prepareSession(true); try { + stage = "initial"; await sendWithConfirmation(prepared); - } catch (retryErr) { - if (retryErr instanceof Error) { - throw retryErr; + } catch (err) { + const shouldRetryWithRestore = + prepared.restoredAt === undefined && isRestorable(prepared); + + if (!shouldRetryWithRestore) { + if (err instanceof Error) { + throw err; + } + throw new Error(String(err), { cause: err }); + } + + stage = "restore_retry"; + prepared = await prepareSession(true); + try { + await sendWithConfirmation(prepared); + } catch (retryErr) { + if (retryErr instanceof Error) { + throw retryErr; + } + throw new Error(String(retryErr), { cause: retryErr }); } - throw new Error(String(retryErr), { cause: retryErr }); } + } catch (err) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.send_failed", + level: "error", + summary: `send failed: ${sessionId}`, + data: { + stage, + reason: err instanceof Error ? err.message : String(err), + }, + }); + throw err; } } @@ -2795,7 +3261,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // metadataToSession sets activity: null, so without enrichment a crashed // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. - const session = metadataToSession(sessionId, raw, projectId, project.sessionPrefix); + const session = metadataToSession( + sessionId, + raw, + { + projectId, + sessionPrefix: project.sessionPrefix, + workspacePathFallback: project.path, + }, + ); const plugins = resolvePlugins(project, selection.agentName); await enrichSessionWithRuntimeState(session, plugins, true, sessionsDir); @@ -2804,6 +3278,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const reason = NON_RESTORABLE_STATUSES.has(session.status) ? `status "${session.status}" is not restorable` : `session is not in a terminal state (status: "${session.status}", activity: "${session.activity}")`; + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore not allowed: ${sessionId}`, + data: { + stage: "validation", + status: session.status, + activity: session.activity, + reason, + }, + }); throw new SessionNotRestorableError(sessionId, reason); } @@ -2824,9 +3312,35 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (!workspaceExists) { // Try to restore workspace if plugin supports it if (!plugins.workspace?.restore) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore workspace failed: ${sessionId}`, + data: { + stage: "workspace_restore", + workspacePath, + reason: "workspace plugin does not support restore", + }, + }); throw new WorkspaceMissingError(workspacePath, "workspace plugin does not support restore"); } if (!session.branch) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `restore workspace failed: ${sessionId}`, + data: { + stage: "workspace_restore", + workspacePath, + reason: "branch metadata is missing", + }, + }); throw new WorkspaceMissingError(workspacePath, "branch metadata is missing"); } try { @@ -2846,6 +3360,19 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await plugins.workspace.postCreate(wsInfo, project); } } catch (err) { + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_failed", + level: "error", + summary: `workspace restore failed: ${sessionId}`, + data: { + stage: "workspace_restore", + workspacePath, + reason: err instanceof Error ? err.message : String(err), + }, + }); throw new WorkspaceMissingError( workspacePath, `restore failed: ${err instanceof Error ? err.message : String(err)}`, @@ -2931,13 +3458,22 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand = restoreCmd; updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" }); } else { + // Agents with native restore can still launch fresh when no resumable + // session metadata exists; this keeps restore from becoming a hard stop. const reason = `${plugins.agent.name}.getRestoreCommand returned null`; updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: reason, }); - if (requiresNativeRestore(plugins.agent.name)) { - throw new SessionNotRestorableError(sessionId, reason); - } + // Surface that AO fell back to a fresh launch instead of native restore. + recordActivityEvent({ + projectId, + sessionId, + source: "session-manager", + kind: "session.restore_fallback", + level: "warn", + summary: `using fresh launch instead of native restore: ${sessionId}`, + data: { agent: plugins.agent.name, reason }, + }); launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); } } else { @@ -3047,6 +3583,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM spawn, spawnOrchestrator, ensureOrchestrator, + relaunchOrchestrator, restore, list, listCached, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d759c7769..66b1ec0ef 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1334,6 +1334,13 @@ export interface LifecycleConfig { mergeCleanupIdleGraceMs: number; } +export interface ObservabilityConfig { + /** Minimum structured log level to persist/mirror. Defaults to "warn". */ + logLevel: ObservabilityLevel; + /** Mirror structured observability logs to stderr. Defaults to false. */ + stderr: boolean; +} + /** Top-level orchestrator configuration (from agent-orchestrator.yaml) */ export interface OrchestratorConfig { /** Optional JSON Schema hint for editor autocomplete/validation. */ @@ -1369,6 +1376,12 @@ export interface OrchestratorConfig { */ lifecycle?: LifecycleConfig; + /** + * Process observability settings. Populated with defaults by Zod when loaded + * from YAML, but optional for hand-constructed tests. + */ + observability?: ObservabilityConfig; + /** Default plugin selections */ defaults: DefaultPlugins; @@ -1845,6 +1858,13 @@ export interface SessionManager { spawn(config: SessionSpawnConfig): Promise; spawnOrchestrator(config: OrchestratorSpawnConfig): Promise; ensureOrchestrator(config: OrchestratorSpawnConfig): Promise; + /** + * Replace the canonical orchestrator with a fresh one. If an orchestrator + * already exists for the project, it is killed, its metadata deleted, and a + * new orchestrator spawned with no carryover state. Ignores + * `orchestratorSessionStrategy` — replacement is the whole point. + */ + relaunchOrchestrator(config: OrchestratorSpawnConfig): Promise; restore(sessionId: SessionId): Promise; list(projectId?: string): Promise; get(sessionId: SessionId): Promise; @@ -1998,11 +2018,14 @@ export class ConfigNotFoundError extends Error { } } +export type ProjectResolveErrorKind = "malformed" | "invalid" | "old-format"; + /** Thrown when a project cannot be resolved into an effective runtime config. */ export class ProjectResolveError extends Error { constructor( public readonly projectId: string, message: string, + public readonly reasonKind?: ProjectResolveErrorKind, ) { super(message); this.name = "ProjectResolveError"; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 958678719..8c1a48524 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -140,11 +140,17 @@ async function readLastLine(filePath: string): Promise { * Reads backwards from end of file — pure Node.js, no external binaries. * * @param filePath - Path to the JSONL file - * @returns Object containing the last entry's type and file mtime, or null if empty/invalid + * @returns Object containing the last entry's `type`, nested `payload.type` (Codex shape), + * top-level `subtype` and `level` (Claude `system`-entry shape), and the file mtime. + * Returns null if the file is empty or unreadable. */ -export async function readLastJsonlEntry( - filePath: string, -): Promise<{ lastType: string | null; payloadType: string | null; modifiedAt: Date } | null> { +export async function readLastJsonlEntry(filePath: string): Promise<{ + lastType: string | null; + payloadType: string | null; + lastSubtype: string | null; + lastLevel: string | null; + modifiedAt: Date; +} | null> { try { const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]); @@ -154,15 +160,23 @@ export async function readLastJsonlEntry( if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { const obj = parsed as Record; const lastType = typeof obj.type === "string" ? obj.type : null; + const lastSubtype = typeof obj.subtype === "string" ? obj.subtype : null; + const lastLevel = typeof obj.level === "string" ? obj.level : null; let payloadType: string | null = null; if (typeof obj.payload === "object" && obj.payload !== null && !Array.isArray(obj.payload)) { const payload = obj.payload as Record; if (typeof payload.type === "string") payloadType = payload.type; } - return { lastType, payloadType, modifiedAt: fileStat.mtime }; + return { lastType, payloadType, lastSubtype, lastLevel, modifiedAt: fileStat.mtime }; } - return { lastType: null, payloadType: null, modifiedAt: fileStat.mtime }; + return { + lastType: null, + payloadType: null, + lastSubtype: null, + lastLevel: null, + modifiedAt: fileStat.mtime, + }; } catch { return null; } diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index aeda52d79..9230ff18b 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -14,6 +14,7 @@ import { safeJsonParse, validateStatus } from "./validation.js"; interface SessionFromMetadataOptions { projectId?: string; + workspacePathFallback?: string; status?: SessionStatus; sessionKind?: SessionKind; activity?: Session["activity"]; @@ -86,7 +87,7 @@ export function sessionFromMetadata( }; })() : null, - workspacePath: meta["worktree"] || null, + workspacePath: meta["worktree"] || options.workspacePathFallback || null, runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle, agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null, createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (options.createdAt ?? new Date()), diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 9654a19dc..ec85fd4ef 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -6,6 +6,17 @@ import { defineConfig } from "vitest/config"; const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + resolve: { + alias: [ + { find: /^@aoagents\/ao-core$/, replacement: resolve(__dirname, "src/index.ts") }, + { + find: /^@aoagents\/ao-core\/scm-webhook-utils$/, + replacement: resolve(__dirname, "src/scm-webhook-utils.ts"), + }, + { find: /^@aoagents\/ao-core\/types$/, replacement: resolve(__dirname, "src/types.ts") }, + { find: /^@aoagents\/ao-core\/utils$/, replacement: resolve(__dirname, "src/utils.ts") }, + ], + }, plugins: [ { name: "raw-markdown", diff --git a/packages/integration-tests/src/notifier-composio.integration.test.ts b/packages/integration-tests/src/notifier-composio.integration.test.ts index 308645a5d..2a47aaecb 100644 --- a/packages/integration-tests/src/notifier-composio.integration.test.ts +++ b/packages/integration-tests/src/notifier-composio.integration.test.ts @@ -9,14 +9,35 @@ import type { NotifyAction } from "@aoagents/ao-core"; import composioPlugin from "@aoagents/ao-plugin-notifier-composio"; import { makeEvent } from "./helpers/event-factory.js"; -const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true }); -const mockClient = { executeAction: mockExecuteAction }; +const mockToolsExecute = vi.fn().mockResolvedValue({ successful: true }); +const mockClient = { tools: { execute: mockToolsExecute } }; + +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} + +function getToolArgs(): Record { + return mockToolsExecute.mock.calls[0][1].arguments; +} + +function getSlackAttachment(): Record { + return JSON.parse(String(getToolArgs().attachments))[0]; +} + +function getSlackActions(): Array> { + return getSlackAttachment().blocks.find((block: any) => block.type === "actions")?.elements ?? []; +} describe("notifier-composio integration", () => { const originalEnv = process.env.COMPOSIO_API_KEY; beforeEach(() => { vi.clearAllMocks(); + mockToolsExecute.mockResolvedValue({ successful: true }); delete process.env.COMPOSIO_API_KEY; }); @@ -29,7 +50,7 @@ describe("notifier-composio integration", () => { }); describe("config -> tool slug routing", () => { - it("slack app routes to SLACK_SEND_MESSAGE with channel", async () => { + it("slack app routes to SLACK_SEND_MESSAGE with normalized channel", async () => { const notifier = composioPlugin.create({ composioApiKey: "key", defaultApp: "slack", @@ -38,53 +59,82 @@ describe("notifier-composio integration", () => { }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", expect.objectContaining({ - action: "SLACK_SEND_MESSAGE", - params: expect.objectContaining({ - channel: "#deploys", + arguments: expect.objectContaining({ + channel: "deploys", }), }), ); }); - it("discord app routes to DISCORD_SEND_MESSAGE with channel_id", async () => { + it("discord app routes to DISCORDBOT_CREATE_MESSAGE with channel_id", async () => { const notifier = composioPlugin.create({ composioApiKey: "key", defaultApp: "discord", + mode: "bot", channelId: "1234567890", _clientOverride: mockClient, }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({ - action: "DISCORD_SEND_MESSAGE", - params: expect.objectContaining({ + arguments: expect.objectContaining({ channel_id: "1234567890", }), }), ); }); - it("gmail app routes to GMAIL_SEND_EMAIL with to/subject/body", async () => { + it("discord webhook mode routes to DISCORDBOT_EXECUTE_WEBHOOK", async () => { const notifier = composioPlugin.create({ composioApiKey: "key", - defaultApp: "gmail", - emailTo: "admin@example.com", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", _clientOverride: mockClient, }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_EXECUTE_WEBHOOK", expect.objectContaining({ - action: "GMAIL_SEND_EMAIL", - params: expect.objectContaining({ - to: "admin@example.com", - subject: "Agent Orchestrator Notification", + arguments: expect.objectContaining({ + webhook_id: "1234567890", + webhook_token: "webhook-token", }), }), ); + expect(mockToolsExecute.mock.calls[0][1]).not.toHaveProperty("connectedAccountId"); + }); + + it("gmail app routes to GMAIL_SEND_EMAIL with recipient/subject/body", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + defaultApp: "gmail", + emailTo: "admin@example.com", + connectedAccountId: "ca_gmail", + _clientOverride: mockClient, + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", + expect.objectContaining({ + connectedAccountId: "ca_gmail", + version: "20260506_01", + arguments: expect.objectContaining({ + recipient_email: "admin@example.com", + subject: "[AO] Session Spawned: app-1", + is_html: true, + }), + }), + ); + expect(getToolArgs().body).toContain(""); + expect(getToolArgs().body).toContain("Session app-1 spawned successfully"); }); }); @@ -98,10 +148,11 @@ describe("notifier-composio integration", () => { makeEvent({ priority: "urgent", type: "ci.failing", sessionId: "app-5" }), ); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; - expect(text).toContain("\u{1F6A8}"); // urgent emoji - expect(text).toContain("ci.failing"); - expect(text).toContain("app-5"); + const attachment = getSlackAttachment(); + expect(attachment.fallback).toContain("Urgent"); + expect(attachment.fallback).toContain("CI failing"); + expect(attachment.blocks[0].text.text).toContain(":rotating_light:"); + expect(attachment.blocks[2].fields[1].text).toContain("app-5"); }); it("includes PR URL when present in event data", async () => { @@ -109,10 +160,25 @@ describe("notifier-composio integration", () => { composioApiKey: "key", _clientOverride: mockClient, }); - await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/99" } })); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { number: 99, url: "https://github.com/org/repo/pull/99" }, + }, + }), + }), + ); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; - expect(text).toContain("https://github.com/org/repo/pull/99"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "View PR" }), + url: "https://github.com/org/repo/pull/99", + }), + ]), + ); }); it("omits PR URL when not a string", async () => { @@ -122,7 +188,7 @@ describe("notifier-composio integration", () => { }); await notifier.notify(makeEvent({ data: { prUrl: 123 } })); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; + const text = mockToolsExecute.mock.calls[0][1].arguments.markdown_text as string; expect(text).not.toContain("PR:"); }); }); @@ -139,9 +205,18 @@ describe("notifier-composio integration", () => { ]; await notifier.notifyWithActions!(makeEvent(), actions); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; - expect(text).toContain("Merge PR: https://github.com/merge"); - expect(text).toContain("- Kill Session"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "Merge PR" }), + url: "https://github.com/merge", + }), + expect.objectContaining({ + text: expect.objectContaining({ text: "Kill Session" }), + value: "/api/kill", + }), + ]), + ); }); }); @@ -154,9 +229,9 @@ describe("notifier-composio integration", () => { }); await notifier.post!("All sessions complete", { channel: "#override" }); - const args = mockExecuteAction.mock.calls[0][0].params; - expect(args.text).toBe("All sessions complete"); - expect(args.channel).toBe("#override"); + const args = mockToolsExecute.mock.calls[0][1].arguments; + expect(args.markdown_text).toBe("All sessions complete"); + expect(args.channel).toBe("override"); }); it("returns null", async () => { @@ -172,10 +247,12 @@ describe("notifier-composio integration", () => { describe("error handling", () => { it("unsuccessful result throws descriptive error", async () => { const failClient = { - executeAction: vi.fn().mockResolvedValue({ - successful: false, - error: "Channel not found", - }), + tools: { + execute: vi.fn().mockResolvedValue({ + successful: false, + error: "Channel not found", + }), + }, }; const notifier = composioPlugin.create({ diff --git a/packages/integration-tests/src/notifier-desktop.integration.test.ts b/packages/integration-tests/src/notifier-desktop.integration.test.ts index 5dcfad0c8..52fe1b075 100644 --- a/packages/integration-tests/src/notifier-desktop.integration.test.ts +++ b/packages/integration-tests/src/notifier-desktop.integration.test.ts @@ -10,9 +10,17 @@ import { makeEvent } from "./helpers/event-factory.js"; vi.mock("node:child_process", () => ({ execFile: vi.fn(), + execFileSync: vi.fn(() => { + throw new Error("not found"); + }), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), })); vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/Users/test"), platform: vi.fn(() => "darwin"), })); @@ -121,8 +129,8 @@ describe("notifier-desktop integration", () => { expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); const args = mockExecFile.mock.calls[0][1] as string[]; - expect(args).toContain("Agent Orchestrator [backend-1]"); - expect(args).toContain("Test msg"); + expect(args).toContain("Session Spawned"); + expect(args.some((arg) => arg.includes("Test msg"))).toBe(true); }); it("linux + urgent -> --urgency=critical before title", async () => { diff --git a/packages/integration-tests/src/notifier-openclaw.integration.test.ts b/packages/integration-tests/src/notifier-openclaw.integration.test.ts index ecf2d88f2..330c085b6 100644 --- a/packages/integration-tests/src/notifier-openclaw.integration.test.ts +++ b/packages/integration-tests/src/notifier-openclaw.integration.test.ts @@ -49,8 +49,12 @@ describe("notifier-openclaw integration", () => { expect(body.sessionKey).toBe("hook:ao:ao-12"); expect(body.wakeMode).toBe("now"); expect(body.deliver).toBe(true); - expect(body.message).toContain("[AO URGENT]"); + expect(body.message).toContain("**AO URGENT** `reaction.escalated`"); expect(body.message).toContain("CI failed 5 times"); + expect(body.message).toContain("- Project: `my-project`"); + expect(body.message).toContain("- Session: `ao-12`"); + expect(body.message).toContain("- attempts: 5"); + expect(body.message).toContain("- reason: ci_failed"); }); it("notifyWithActions formats escalation header/context and appends action labels", async () => { @@ -73,9 +77,14 @@ describe("notifier-openclaw integration", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.sessionKey).toBe("hook:ao:ao-5"); - expect(body.message).toContain("[AO ACTION] ao-5 ci.failing"); - expect(body.message).toContain('Context: {"checkName":"lint"}'); - expect(body.message).toContain("Actions available: retry, kill"); + expect(body.message).toContain("**AO ACTION** `ci.failing`"); + expect(body.message).toContain("CI check failed on app-1"); + expect(body.message).toContain("- Project: `my-project`"); + expect(body.message).toContain("- Session: `ao-5`"); + expect(body.message).toContain("- checkName: lint"); + expect(body.message).toContain("**Actions**"); + expect(body.message).toContain("- retry"); + expect(body.message).toContain("- kill"); }); it("uses explicit deliver=true in hooks payload", async () => { diff --git a/packages/integration-tests/src/notifier-slack.integration.test.ts b/packages/integration-tests/src/notifier-slack.integration.test.ts index e49edabee..7204ccfe7 100644 --- a/packages/integration-tests/src/notifier-slack.integration.test.ts +++ b/packages/integration-tests/src/notifier-slack.integration.test.ts @@ -16,6 +16,29 @@ function mockFetchOk() { }); } +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} + +function getAttachment(body: Record): Record { + return body.attachments[0]; +} + +function getBlocks(body: Record): Array> { + return getAttachment(body).blocks; +} + +function expectDefined(value: T | undefined): asserts value is T { + expect(value).toBeDefined(); + if (value === undefined) { + throw new Error("Expected value to be defined"); + } +} + describe("notifier-slack integration", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -43,61 +66,62 @@ describe("notifier-slack integration", () => { sessionId: "backend-3", projectId: "integrator", message: "CI is failing on backend-3", - data: { - prUrl: "https://github.com/org/repo/pull/42", - ciStatus: "failing", - }, + data: makeV3Data({ + subject: { + session: { id: "backend-3", projectId: "integrator" }, + pr: { number: 42, url: "https://github.com/org/repo/pull/42" }, + }, + ci: { status: "failing" }, + }), }), ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const blocks = getBlocks(body); // Verify full structure expect(body.username).toBe("TestBot"); expect(body.channel).toBe("#deploys"); // Block 0: header - expect(body.blocks[0].type).toBe("header"); - expect(body.blocks[0].text.type).toBe("plain_text"); - expect(body.blocks[0].text.text).toContain(":rotating_light:"); - expect(body.blocks[0].text.text).toContain("session.spawned"); - expect(body.blocks[0].text.text).toContain("backend-3"); - expect(body.blocks[0].text.emoji).toBe(true); + expect(blocks[0].type).toBe("header"); + expect(blocks[0].text.type).toBe("plain_text"); + expect(blocks[0].text.text).toContain(":rotating_light:"); + expect(blocks[0].text.text).toContain("Session Spawned"); + expect(blocks[0].text.emoji).toBe(true); // Block 1: message section - expect(body.blocks[1].type).toBe("section"); - expect(body.blocks[1].text.type).toBe("mrkdwn"); - expect(body.blocks[1].text.text).toBe("CI is failing on backend-3"); + expect(blocks[1].type).toBe("section"); + expect(blocks[1].text.type).toBe("mrkdwn"); + expect(blocks[1].text.text).toBe("CI is failing on backend-3"); - // Block 2: context with project/priority/time - expect(body.blocks[2].type).toBe("context"); - expect(body.blocks[2].elements[0].text).toContain("*Project:* integrator"); - expect(body.blocks[2].elements[0].text).toContain("*Priority:* urgent"); + // Field block includes project/session/priority metadata + expect(blocks[2].type).toBe("section"); + expect(blocks[2].fields[0].text).toContain("*Project*"); + expect(blocks[2].fields[0].text).toContain("integrator"); + expect(blocks[2].fields[2].text).toContain("*Priority*"); + expect(blocks[2].fields[2].text).toContain("Urgent"); - // Block 3: PR link - const prBlock = body.blocks.find( - (b: Record) => - b.type === "section" && - typeof (b as { text?: { text?: string } }).text?.text === "string" && - (b as { text: { text: string } }).text.text.includes("View Pull Request"), - ); - expect(prBlock).toBeDefined(); - expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42"); + // PR link is rendered as an action button + const actionsBlock = blocks.find((b: Record) => b.type === "actions"); + expectDefined(actionsBlock); + expect(actionsBlock.elements[0].text.text).toBe("View PR"); + expect(actionsBlock.elements[0].url).toBe("https://github.com/org/repo/pull/42"); // CI status block - const ciBlock = body.blocks.find( + const ciBlock = blocks.find( (b: Record) => b.type === "context" && Array.isArray((b as { elements?: unknown[] }).elements) && typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" && (b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"), ); - expect(ciBlock).toBeDefined(); + expectDefined(ciBlock); expect(ciBlock.elements[0].text).toContain(":x:"); expect(ciBlock.elements[0].text).toContain("failing"); // Last block: divider - expect(body.blocks[body.blocks.length - 1].type).toBe("divider"); + expect(blocks[blocks.length - 1].type).toBe("divider"); }); it("passing CI uses check mark emoji", async () => { @@ -105,16 +129,17 @@ describe("notifier-slack integration", () => { vi.stubGlobal("fetch", fetchMock); const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + await notifier.notify(makeEvent({ data: makeV3Data({ ci: { status: "passing" } }) })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( + const ciBlock = getBlocks(body).find( (b: Record) => b.type === "context" && Array.isArray((b as { elements?: unknown[] }).elements) && typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" && (b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"), ); + expectDefined(ciBlock); expect(ciBlock.elements[0].text).toContain(":white_check_mark:"); }); @@ -126,8 +151,8 @@ describe("notifier-slack integration", () => { await notifier.notify(makeEvent({ data: {} })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - // Should have: header, section, context, divider = 4 blocks - expect(body.blocks).toHaveLength(4); + // Should have: header, message, fields, timestamp context, divider. + expect(getBlocks(body)).toHaveLength(5); }); }); @@ -147,7 +172,7 @@ describe("notifier-slack integration", () => { await notifier.notify(makeEvent({ priority })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.blocks[0].text.text).toContain(expectedEmoji); + expect(getBlocks(body)[0].text.text).toContain(expectedEmoji); }, ); }); @@ -166,8 +191,10 @@ describe("notifier-slack integration", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); - expect(actionsBlock).toBeDefined(); + const actionsBlock = getBlocks(body).find( + (b: Record) => b.type === "actions", + ); + expectDefined(actionsBlock); expect(actionsBlock.elements).toHaveLength(2); // URL button @@ -194,7 +221,10 @@ describe("notifier-slack integration", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getBlocks(body).find( + (b: Record) => b.type === "actions", + ); + expectDefined(actionsBlock); expect(actionsBlock.elements).toHaveLength(1); expect(actionsBlock.elements[0].text.text).toBe("Has Link"); }); @@ -207,7 +237,9 @@ describe("notifier-slack integration", () => { await notifier.notifyWithActions!(makeEvent(), [{ label: "No Link" }]); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getBlocks(body).find( + (b: Record) => b.type === "actions", + ); expect(actionsBlock).toBeUndefined(); }); }); @@ -287,7 +319,12 @@ describe("notifier-slack integration", () => { await notifier.notify(makeEvent({ timestamp: ts })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const contextText = body.blocks[2].elements[0].text; + const contextBlock = getBlocks(body).find( + (block) => + block.type === "context" && + block.elements?.[0]?.text?.includes("Sent by Agent Orchestrator"), + ); + const contextText = contextBlock!.elements[0].text; const unixTs = Math.floor(ts.getTime() / 1000); expect(contextText).toContain(`(query: string, variables: Record): Promise { +function linearGraphQL( + query: string, + variables: Record, + options: LinearGraphQLOptions = {}, +): Promise { if (!LINEAR_API_KEY) { throw new Error("linearGraphQL requires LINEAR_API_KEY"); } const body = JSON.stringify({ query, variables }); + const maxAttempts = options.maxAttempts ?? 3; + const retryDelayMs = options.retryDelayMs ?? 1_000; + const timeoutMs = options.timeoutMs ?? 30_000; async function executeWithRetry(attempt = 1): Promise { try { @@ -95,9 +108,9 @@ function linearGraphQL(query: string, variables: Record): Pr }, ); - req.setTimeout(30_000, () => { + req.setTimeout(timeoutMs, () => { req.destroy(); - reject(new Error("Linear API request timed out")); + reject(new Error(`Linear API request timed out after ${timeoutMs}ms`)); }); req.on("error", (err) => reject(err)); @@ -105,8 +118,8 @@ function linearGraphQL(query: string, variables: Record): Pr req.end(); }); } catch (err) { - if (attempt < 3) { - await new Promise((resolve) => setTimeout(resolve, 1_000)); + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); return executeWithRetry(attempt + 1); } throw err; @@ -116,6 +129,21 @@ function linearGraphQL(query: string, variables: Record): Pr return executeWithRetry(); } +async function retryExternal(operation: () => Promise, attempts = 3): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await operation(); + } catch (err) { + lastError = err; + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + } + throw lastError; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -144,13 +172,15 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // ------------------------------------------------------------------------- beforeAll(async () => { - const result = await tracker.createIssue!( - { - title: `[AO Integration Test] ${new Date().toISOString()}`, - description: "Automated integration test issue. Safe to delete if found lingering.", - priority: 4, // Low - }, - project, + const result = await retryExternal(() => + tracker.createIssue!( + { + title: `[AO Integration Test] ${new Date().toISOString()}`, + description: "Automated integration test issue. Safe to delete if found lingering.", + priority: 4, // Low + }, + project, + ), ); issueIdentifier = result.id; @@ -167,7 +197,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { issueUuid = undefined; } } - }, 30_000); + }, 120_000); // ------------------------------------------------------------------------- // Cleanup — archive the test issue so it doesn't clutter the board. @@ -179,7 +209,18 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { if (!issueIdentifier) return; try { - if (issueUuid && LINEAR_API_KEY) { + if (LINEAR_API_KEY) { + const cleanupRequest = { maxAttempts: 1, timeoutMs: 5_000 }; + if (!issueUuid) { + const data = await linearGraphQL<{ issue: { id: string } }>( + `query($id: String!) { issue(id: $id) { id } }`, + { id: issueIdentifier }, + cleanupRequest, + ); + issueUuid = data.issue.id; + } + + if (!issueUuid) return; await linearGraphQL( `mutation($id: String!) { issueUpdate(id: $id, input: { trashed: true }) { @@ -187,6 +228,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { } }`, { id: issueUuid }, + cleanupRequest, ); } else { // Composio-only: best-effort close via plugin @@ -195,7 +237,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { } catch { // Best-effort cleanup } - }, 15_000); + }, 60_000); // ------------------------------------------------------------------------- // Test cases @@ -252,10 +294,10 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // Linear API has eventual consistency — poll until the issue appears in list results const found = await pollUntil( async () => { - const issues = await tracker.listIssues!({ state: "open", limit: 50 }, project); + const issues = await tracker.listIssues!({ state: "open", limit: 100 }, project); return issues.find((i: { id: string }) => i.id === issueIdentifier); }, - { timeoutMs: 5_000, intervalMs: 500 }, + { timeoutMs: 15_000, intervalMs: 1_000 }, ); expect(found).toBeDefined(); diff --git a/packages/notifier-macos/assets/AppIcon.svg b/packages/notifier-macos/assets/AppIcon.svg new file mode 100644 index 000000000..a3fba9ecc --- /dev/null +++ b/packages/notifier-macos/assets/AppIcon.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/notifier-macos/package.json b/packages/notifier-macos/package.json new file mode 100644 index 000000000..5b5a760b1 --- /dev/null +++ b/packages/notifier-macos/package.json @@ -0,0 +1,40 @@ +{ + "name": "@aoagents/ao-notifier-macos", + "version": "0.6.0", + "description": "Native macOS notification helper app for AO", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "assets", + "src", + "scripts" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/notifier-macos" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "node scripts/build.mjs", + "clean": "rm -rf dist", + "sign": "node scripts/sign.mjs", + "notarize": "node scripts/notarize.mjs" + } +} diff --git a/packages/notifier-macos/scripts/build.mjs b/packages/notifier-macos/scripts/build.mjs new file mode 100644 index 000000000..327e5d685 --- /dev/null +++ b/packages/notifier-macos/scripts/build.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +import { Buffer } from "node:buffer"; +import { execFileSync } from "node:child_process"; +import console from "node:console"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(__dirname, ".."); +const distDir = resolve(packageDir, "dist"); +const appName = "AO Notifier.app"; +const appDir = resolve(distDir, appName); +const contentsDir = resolve(appDir, "Contents"); +const macOsDir = resolve(contentsDir, "MacOS"); +const resourcesDir = resolve(contentsDir, "Resources"); +const executablePath = resolve(macOsDir, "ao-notifier"); +const placeholderMarkerPath = resolve(resourcesDir, "ao-notifier-placeholder"); +const swiftSource = resolve(packageDir, "src", "AONotifier.swift"); +const sourceIconSvg = resolve(packageDir, "assets", "AppIcon.svg"); + +function commandExists(command) { + try { + execFileSync(command, ["--version"], { stdio: "ignore", windowsHide: true }); + return true; + } catch (error) { + return error?.code !== "ENOENT"; + } +} + +function crc32(buffer) { + let crc = ~0; + for (let i = 0; i < buffer.length; i += 1) { + crc ^= buffer[i]; + for (let j = 0; j < 8; j += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return ~crc >>> 0; +} + +function pngChunk(type, data) { + const typeBuffer = Buffer.from(type); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0); + return Buffer.concat([length, typeBuffer, data, crc]); +} + +function makePng(size) { + const raw = Buffer.alloc((size * 4 + 1) * size); + for (let y = 0; y < size; y += 1) { + const rowStart = y * (size * 4 + 1); + raw[rowStart] = 0; + for (let x = 0; x < size; x += 1) { + const offset = rowStart + 1 + x * 4; + const inA = x > size * 0.22 && x < size * 0.42 && y > size * 0.22 && y < size * 0.78; + const inO = + x > size * 0.52 && + x < size * 0.80 && + y > size * 0.22 && + y < size * 0.78 && + !(x > size * 0.60 && x < size * 0.72 && y > size * 0.34 && y < size * 0.66); + const inABar = x > size * 0.30 && x < size * 0.50 && y > size * 0.47 && y < size * 0.57; + const mark = inA || inO || inABar; + raw[offset] = mark ? 255 : 20; + raw[offset + 1] = mark ? 255 : 24; + raw[offset + 2] = mark ? 255 : 32; + raw[offset + 3] = 255; + } + } + + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; + header[9] = 6; + header[10] = 0; + header[11] = 0; + header[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", header), + pngChunk("IDAT", zlib.deflateSync(raw)), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +function writeInfoPlist() { + writeFileSync( + resolve(contentsDir, "Info.plist"), + ` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ao-notifier + CFBundleIdentifier + com.aoagents.notifier + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AO Notifier + CFBundleDisplayName + AO Notifier + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.6.0 + CFBundleVersion + 0.6.0 + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSUserNotificationAlertStyle + alert + + +`, + ); +} + +function writeIcon() { + const iconsetDir = resolve(resourcesDir, "AppIcon.iconset"); + rmSync(iconsetDir, { recursive: true, force: true }); + mkdirSync(iconsetDir, { recursive: true }); + const sizes = [16, 32, 64, 128, 256, 512, 1024]; + + const canRenderSvgIcon = existsSync(sourceIconSvg) && commandExists("sips"); + if (canRenderSvgIcon) { + for (const size of sizes) { + execFileSync( + "sips", + [ + "-s", + "format", + "png", + "--resampleHeightWidth", + String(size), + String(size), + sourceIconSvg, + "--out", + resolve(iconsetDir, `icon_${size}x${size}.png`), + ], + { stdio: "ignore" }, + ); + } + } else { + for (const size of sizes) { + writeFileSync(resolve(iconsetDir, `icon_${size}x${size}.png`), makePng(size)); + } + } + + if (process.platform === "darwin" && commandExists("iconutil")) { + try { + execFileSync("iconutil", ["-c", "icns", iconsetDir, "-o", resolve(resourcesDir, "AppIcon.icns")], { + stdio: "ignore", + }); + rmSync(iconsetDir, { recursive: true, force: true }); + } catch { + // The PNG iconset remains usable as a build artifact even if iconutil is unavailable. + } + } +} + +function writeDistIndex() { + writeFileSync( + resolve(distDir, "index.js"), + `import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const appName = "AO Notifier.app"; +export const bundleId = "com.aoagents.notifier"; + +export function getBundledAppPath() { + return resolve(__dirname, appName); +} +`, + ); + writeFileSync( + resolve(distDir, "index.d.ts"), + `export declare const appName = "AO Notifier.app"; +export declare const bundleId = "com.aoagents.notifier"; +export declare function getBundledAppPath(): string; +`, + ); +} + +function writePlaceholderExecutable() { + writeFileSync( + executablePath, + `#!/usr/bin/env sh +echo "AO Notifier.app requires macOS with Swift tooling to build." >&2 +exit 1 +`, + { mode: 0o755 }, + ); + writeFileSync(placeholderMarkerPath, "native macOS build unavailable\n"); +} + +rmSync(distDir, { recursive: true, force: true }); +mkdirSync(macOsDir, { recursive: true }); +mkdirSync(resourcesDir, { recursive: true }); +writeInfoPlist(); +writeIcon(); +writeDistIndex(); + +if (process.platform !== "darwin" || !commandExists("swiftc")) { + writePlaceholderExecutable(); + console.log("Built AO Notifier placeholder app (native macOS build unavailable)."); + process.exit(0); +} + +execFileSync( + "swiftc", + [ + "-O", + "-framework", + "AppKit", + "-framework", + "Foundation", + "-framework", + "UserNotifications", + swiftSource, + "-o", + executablePath, + ], + { stdio: "inherit" }, +); + +if (commandExists("codesign")) { + try { + execFileSync("codesign", ["--force", "--sign", "-", appDir], { stdio: "ignore" }); + } catch { + console.warn("Could not ad-hoc sign AO Notifier.app."); + } +} + +console.log(`Built ${appDir}`); diff --git a/packages/notifier-macos/scripts/notarize.mjs b/packages/notifier-macos/scripts/notarize.mjs new file mode 100644 index 000000000..2c9353765 --- /dev/null +++ b/packages/notifier-macos/scripts/notarize.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import console from "node:console"; +import { existsSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(__dirname, "..", "dist", "AO Notifier.app"); +const zipPath = resolve(__dirname, "..", "dist", "AO Notifier.zip"); + +const appleId = process.env["APPLE_NOTARY_APPLE_ID"]; +const teamId = process.env["APPLE_NOTARY_TEAM_ID"]; +const password = process.env["APPLE_NOTARY_PASSWORD"]; + +if (process.platform !== "darwin") { + console.log("Skipping macOS notarization on non-darwin platform."); + process.exit(0); +} + +if (!appleId || !teamId || !password) { + console.error( + "Set APPLE_NOTARY_APPLE_ID, APPLE_NOTARY_TEAM_ID, and APPLE_NOTARY_PASSWORD to notarize.", + ); + process.exit(1); +} + +if (!existsSync(appDir)) { + console.error(`Missing app bundle: ${appDir}`); + process.exit(1); +} + +rmSync(zipPath, { force: true }); +execFileSync("ditto", ["-c", "-k", "--keepParent", appDir, zipPath], { stdio: "inherit" }); +execFileSync( + "xcrun", + [ + "notarytool", + "submit", + zipPath, + "--apple-id", + appleId, + "--team-id", + teamId, + "--password", + password, + "--wait", + ], + { stdio: "inherit" }, +); +execFileSync("xcrun", ["stapler", "staple", appDir], { stdio: "inherit" }); +console.log("Notarized and stapled AO Notifier.app."); diff --git a/packages/notifier-macos/scripts/sign.mjs b/packages/notifier-macos/scripts/sign.mjs new file mode 100644 index 000000000..50437bf43 --- /dev/null +++ b/packages/notifier-macos/scripts/sign.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import console from "node:console"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(__dirname, "..", "dist", "AO Notifier.app"); +const identity = process.env["APPLE_CODESIGN_IDENTITY"] ?? "-"; + +if (process.platform !== "darwin") { + console.log("Skipping macOS signing on non-darwin platform."); + process.exit(0); +} + +if (!existsSync(appDir)) { + console.error(`Missing app bundle: ${appDir}`); + process.exit(1); +} + +execFileSync("codesign", ["--force", "--deep", "--options", "runtime", "--sign", identity, appDir], { + stdio: "inherit", +}); +console.log(`Signed AO Notifier.app with ${identity === "-" ? "ad-hoc identity" : identity}.`); diff --git a/packages/notifier-macos/src/AONotifier.swift b/packages/notifier-macos/src/AONotifier.swift new file mode 100644 index 000000000..425d01643 --- /dev/null +++ b/packages/notifier-macos/src/AONotifier.swift @@ -0,0 +1,394 @@ +import AppKit +import Foundation +import UserNotifications + +let appName = "AO Notifier" +let appVersion = "0.6.0" +let bundleId = "com.aoagents.notifier" + +struct NotifyPayload: Codable { + struct Event: Codable { + let id: String + let type: String + let priority: String + let sessionId: String + let projectId: String + let timestamp: String + } + + struct Action: Codable { + let label: String + let url: String? + let callbackEndpoint: String? + } + + let title: String + let subtitle: String? + let body: String + let sound: Bool + let notificationId: String? + let threadId: String? + let defaultOpenUrl: String? + let event: Event + let actions: [Action]? +} + +final class NotificationResponseDelegate: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + let actionIdentifier = response.actionIdentifier + + if actionIdentifier == UNNotificationDefaultActionIdentifier { + if let defaultOpenUrl = userInfo["defaultOpenUrl"] as? String { + openUrl(defaultOpenUrl) + completionHandler() + return + } + if let actionUrls = userInfo["actionUrls"] as? [String: String], + let fallbackUrl = actionUrls.sorted(by: { $0.key < $1.key }).first?.value + { + openUrl(fallbackUrl) + } + completionHandler() + return + } + + if let actionCallbacks = userInfo["actionCallbacks"] as? [String: String], + let callbackEndpoint = actionCallbacks[actionIdentifier] + { + postCallback(callbackEndpoint) + completionHandler() + return + } + + if let actionUrls = userInfo["actionUrls"] as? [String: String], + let url = actionUrls[actionIdentifier] + { + openUrl(url) + } + + completionHandler() + } +} + +let delegate = NotificationResponseDelegate() + +func jsonEscape(_ value: String) -> String { + let data = try? JSONSerialization.data(withJSONObject: [value], options: []) + let encoded = String(data: data ?? Data("[]".utf8), encoding: .utf8) ?? "[\"\"]" + return String(encoded.dropFirst().dropLast()) +} + +func printJson(_ pairs: [(String, String)]) { + let body = pairs.map { key, value in + "\"\(key)\":\(jsonEscape(value))" + }.joined(separator: ",") + print("{\(body)}") +} + +func printJsonObject(_ value: Any) { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]), + let json = String(data: data, encoding: .utf8) + else { + print("{}") + return + } + print(json) +} + +func openUrl(_ rawUrl: String?) { + guard let rawUrl = rawUrl, let url = URL(string: rawUrl) else { return } + NSWorkspace.shared.open(url) +} + +func postCallback(_ rawUrl: String?) { + guard let rawUrl = rawUrl, let url = URL(string: rawUrl) else { return } + guard url.scheme == "http" || url.scheme == "https" else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = Data("{\"source\":\"ao-notifier\"}".utf8) + + let semaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: request) { _, _, _ in + semaphore.signal() + } + task.resume() + _ = semaphore.wait(timeout: .now() + 10) +} + +func waitForSettings(_ center: UNUserNotificationCenter) -> UNNotificationSettings? { + let semaphore = DispatchSemaphore(value: 0) + var resolved: UNNotificationSettings? + center.getNotificationSettings { settings in + resolved = settings + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + return resolved +} + +func notificationSettingStatus(_ setting: UNNotificationSetting) -> String { + switch setting { + case .enabled: + return "enabled" + case .disabled: + return "disabled" + case .notSupported: + return "not_supported" + @unknown default: + return "unknown" + } +} + +func permissionStatus() -> String { + guard let settings = waitForSettings(UNUserNotificationCenter.current()) else { + return "unknown" + } + switch settings.authorizationStatus { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .notDetermined: + return "not_determined" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown" + } +} + +func requestPermission() -> Bool { + let center = UNUserNotificationCenter.current() + let semaphore = DispatchSemaphore(value: 0) + var granted = false + center.requestAuthorization(options: [.alert, .sound, .badge]) { allowed, _ in + granted = allowed + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 30) + return granted +} + +func waitForDeliveredNotifications(_ center: UNUserNotificationCenter) -> [UNNotification] { + let semaphore = DispatchSemaphore(value: 0) + var resolved: [UNNotification] = [] + center.getDeliveredNotifications { notifications in + resolved = notifications + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + return resolved +} + +func printDeliveredNotifications() { + let delivered = waitForDeliveredNotifications(UNUserNotificationCenter.current()) + let rows = delivered.map { notification -> [String: Any] in + let content = notification.request.content + return [ + "identifier": notification.request.identifier, + "threadIdentifier": content.threadIdentifier, + "categoryIdentifier": content.categoryIdentifier, + "title": content.title, + "subtitle": content.subtitle, + "body": content.body, + "badge": content.badge?.intValue ?? 0, + "eventId": content.userInfo["eventId"] as? String ?? "", + "eventType": content.userInfo["eventType"] as? String ?? "", + "sessionId": content.userInfo["sessionId"] as? String ?? "", + "projectId": content.userInfo["projectId"] as? String ?? "", + "notificationId": content.userInfo["notificationId"] as? String ?? "", + "threadId": content.userInfo["threadId"] as? String ?? "", + ] + } + printJsonObject([ + "count": rows.count, + "notifications": rows, + ]) +} + +func clearDeliveredNotifications() { + let center = UNUserNotificationCenter.current() + let identifiers = waitForDeliveredNotifications(center).map { $0.request.identifier } + if identifiers.isEmpty { + return + } + + center.removeDeliveredNotifications(withIdentifiers: identifiers) + NSApplication.shared.dockTile.badgeLabel = nil + Thread.sleep(forTimeInterval: 0.5) +} + +func decodePayload(_ base64: String) throws -> NotifyPayload { + guard let data = Data(base64Encoded: base64) else { + throw NSError(domain: appName, code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 payload"]) + } + return try JSONDecoder().decode(NotifyPayload.self, from: data) +} + +func fallbackNotificationId(_ eventId: String) -> String { + return "\(eventId).\(UUID().uuidString)" +} + +func fallbackThreadId() -> String { + return "ao.notifications" +} + +func sendNotification(_ payload: NotifyPayload) throws { + let center = UNUserNotificationCenter.current() + center.delegate = delegate + + var actionUrls: [String: String] = [:] + var actionCallbacks: [String: String] = [:] + let configuredUrlActions = (payload.actions ?? []).enumerated().compactMap { index, action -> UNNotificationAction? in + guard action.url != nil || action.callbackEndpoint != nil else { return nil } + let identifier = "ao.action.\(index)" + if let url = action.url { + actionUrls[identifier] = url + } + if let callbackEndpoint = action.callbackEndpoint { + actionCallbacks[identifier] = callbackEndpoint + } + return UNNotificationAction( + identifier: identifier, + title: action.label, + options: [.foreground] + ) + } + + let categoryId: String? + if configuredUrlActions.isEmpty { + categoryId = nil + } else { + let id = "ao.event.\(payload.event.id)" + let category = UNNotificationCategory( + identifier: id, + actions: configuredUrlActions, + intentIdentifiers: [], + options: [] + ) + center.setNotificationCategories([category]) + categoryId = id + } + + let content = UNMutableNotificationContent() + let threadId = payload.threadId ?? fallbackThreadId() + content.title = payload.title + if let subtitle = payload.subtitle { + content.subtitle = subtitle + } + content.body = payload.body + content.threadIdentifier = threadId + if let categoryId = categoryId { + content.categoryIdentifier = categoryId + } + content.badge = NSNumber(value: waitForDeliveredNotifications(center).count + 1) + if payload.sound { + content.sound = .default + } + + var userInfo: [String: Any] = [ + "eventId": payload.event.id, + "eventType": payload.event.type, + "sessionId": payload.event.sessionId, + "projectId": payload.event.projectId, + "threadId": threadId, + "actionUrls": actionUrls, + "actionCallbacks": actionCallbacks, + ] + let requestId = payload.notificationId ?? fallbackNotificationId(payload.event.id) + userInfo["notificationId"] = requestId + if let defaultOpenUrl = payload.defaultOpenUrl { + userInfo["defaultOpenUrl"] = defaultOpenUrl + } + content.userInfo = userInfo + + let request = UNNotificationRequest(identifier: requestId, content: content, trigger: nil) + let semaphore = DispatchSemaphore(value: 0) + var sendError: Error? + center.add(request) { error in + sendError = error + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + if let sendError = sendError { + throw sendError + } +} + +func runCommand(_ args: [String]) -> Int32 { + let center = UNUserNotificationCenter.current() + center.delegate = delegate + + guard let command = args.first else { + RunLoop.current.run(until: Date().addingTimeInterval(5)) + return 0 + } + + do { + switch command { + case "--version-json": + printJson([ + ("name", appName), + ("version", appVersion), + ("bundleId", bundleId), + ]) + return 0 + case "--permission-status-json": + let settings = waitForSettings(center) + printJson([ + ("status", permissionStatus()), + ("badge", settings.map { notificationSettingStatus($0.badgeSetting) } ?? "unknown"), + ("bundleId", bundleId), + ]) + return 0 + case "--delivered-json": + printDeliveredNotifications() + return 0 + case "--clear-delivered": + clearDeliveredNotifications() + printJson([ + ("cleared", "true"), + ("bundleId", bundleId), + ]) + return 0 + case "--request-permission": + let granted = requestPermission() + let settings = waitForSettings(center) + printJson([ + ("status", granted ? "authorized" : permissionStatus()), + ("badge", settings.map { notificationSettingStatus($0.badgeSetting) } ?? "unknown"), + ("bundleId", bundleId), + ]) + return granted ? 0 : 2 + case "--notify-base64": + guard args.count >= 2 else { + fputs("Missing --notify-base64 payload\n", stderr) + return 64 + } + let status = permissionStatus() + if status == "not_determined" { + _ = requestPermission() + } + try sendNotification(decodePayload(args[1])) + return 0 + default: + fputs("Unknown command: \(command)\n", stderr) + return 64 + } + } catch { + fputs("\(error.localizedDescription)\n", stderr) + return 1 + } +} + +exit(runCommand(Array(CommandLine.arguments.dropFirst()))) diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index ff9a78bcb..332018d41 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -1,9 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + mkdirSync, + realpathSync, + writeFileSync, + rmSync, + utimesSync, +} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { toClaudeProjectPath, create } from "../index.js"; -import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core"; +import { resetWarnedReaddirPaths } from "../activity-detection.js"; +import { + createActivitySignal, + readLastActivityEntry, + type ActivityState, + type Session, + type RuntimeHandle, +} from "@aoagents/ao-core"; // Mock homedir() so getActivityState looks in our temp dir vi.mock("node:os", async (importOriginal) => { @@ -57,6 +72,16 @@ function writeJsonl( } } +function writeActivityLog(state: ActivityState, ageMs = 0): void { + const ts = new Date(Date.now() - ageMs).toISOString(); + const aoDir = join(workspacePath, ".ao"); + mkdirSync(aoDir, { recursive: true }); + writeFileSync( + join(aoDir, "activity.jsonl"), + JSON.stringify({ ts, state, source: "terminal" }) + "\n", + ); +} + // ============================================================================= // toClaudeProjectPath // ============================================================================= @@ -99,7 +124,12 @@ describe("Claude Code Activity Detection", () => { const agent = create(); beforeEach(() => { - fakeHome = mkdtempSync(join(tmpdir(), "ao-activity-test-")); + // realpathSync because /var/folders/... is a symlink to /private/var/folders/... + // on macOS. getClaudeActivityState resolves symlinks before slugifying (so the + // slug matches what Claude wrote), so the test setup must do the same — otherwise + // the test JSONL lives under one slug and the code looks under another. + // homedir() is mocked to fakeHome, so its realpath flows through naturally. + fakeHome = realpathSync(mkdtempSync(join(tmpdir(), "ao-activity-test-"))); workspacePath = join(fakeHome, "workspace"); mkdirSync(workspacePath, { recursive: true }); @@ -110,6 +140,9 @@ describe("Claude Code Activity Detection", () => { // Mock isProcessRunning to always return true (we test exited separately) vi.spyOn(agent, "isProcessRunning").mockResolvedValue(true); + + // Reset module-level warn dedupe so each test starts fresh. + resetWarnedReaddirPaths(); }); afterEach(() => { @@ -141,23 +174,174 @@ describe("Claude Code Activity Detection", () => { // Fallback cases (no JSONL data available) // ----------------------------------------------------------------------- - it("returns 'idle' when no session file exists yet", async () => { - // projectDir exists but is empty — no .jsonl files yet (freshly spawned session) - const session = makeSession(); - const result = await agent.getActivityState(session); - expect(result?.state).toBe("idle"); - // timestamp must be session.createdAt so stuck-detection can fire eventually - expect(result?.timestamp).toBe(session.createdAt); + it("returns null when no session file or AO activity entry exists yet", async () => { + // projectDir exists but is empty, and the AO safety-net log is absent. + expect(await agent.getActivityState(makeSession())).toBeNull(); }); it("returns null when no workspacePath", async () => { expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull(); }); - it("returns 'idle' when project directory does not exist", async () => { - // Process is running but no Claude project dir yet — treat as idle + it("logs a warning when ~/.claude/projects/ is unreadable (EACCES)", async () => { + // Make the existing project dir unreadable by stripping owner-read. + // ENOENT (dir missing) is normal and stays silent; EACCES/EPERM must + // surface so users can debug a silent-idle session. + const { chmodSync } = await import("node:fs"); + chmodSync(projectDir, 0o000); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = await agent.getActivityState(makeSession()); + expect(result).toBeNull(); + expect(warn).toHaveBeenCalledOnce(); + // Non-capturing group: alternation MUST stay inside `(?:...)` or it + // would parse as `(failed to read.*EACCES) | (EPERM)` and any string + // containing the literal `EPERM` would pass the assertion. + expect(warn.mock.calls[0]?.[0]).toMatch(/failed to read.*(?:EACCES|EPERM)/); + } finally { + chmodSync(projectDir, 0o755); + warn.mockRestore(); + } + }); + + it("only warns ONCE per path across multiple polls (no log flood)", async () => { + // Real bug this guards against: getActivityState runs on a polling + // interval; without the dedupe set, a single EACCES path would log + // 60+ lines/minute indefinitely. + const { chmodSync } = await import("node:fs"); + chmodSync(projectDir, 0o000); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await agent.getActivityState(makeSession()); + await agent.getActivityState(makeSession()); + await agent.getActivityState(makeSession()); + expect(warn).toHaveBeenCalledOnce(); + } finally { + chmodSync(projectDir, 0o755); + warn.mockRestore(); + } + }); + + it("does NOT log when project dir simply doesn't exist (ENOENT is normal)", async () => { + const badPath = join(fakeHome, "this-workspace-never-existed"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await agent.getActivityState(makeSession({ workspacePath: badPath })); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("prefers UUID-named JSONL when session.metadata.claudeSessionUuid is set", async () => { + // Multi-session-in-one-worktree case: two .jsonl files exist, the + // newest-mtime one is from a DIFFERENT session, and we don't want + // to misread that. Use the metadata UUID to pick the right one. + const myUuid = "aaa-111"; + // Newest-mtime file is the OTHER session's JSONL (would be wrong). + writeJsonl([{ type: "progress", status: "doing other work" }], 0, "bbb-222.jsonl"); + // My session's JSONL is older, but it's the one we want. + writeJsonl( + [{ type: "assistant", message: { content: "my session" } }], + 10_000, + `${myUuid}.jsonl`, + ); + + const session = makeSession({ metadata: { claudeSessionUuid: myUuid } }); + const result = await agent.getActivityState(session); + // 10s old + `assistant` → ready (NOT `active` from the other session's progress) + expect(result?.state).toBe("ready"); + }); + + it("falls back to newest-mtime when UUID-named file doesn't exist yet", async () => { + // Session just spawned, getSessionInfo hasn't captured the UUID yet, + // OR the UUID was captured but the file was rotated/removed. + // Should still find activity via newest-mtime fallback. + writeJsonl([{ type: "user", message: { content: "hi" } }], 0, "actual-session.jsonl"); + + const session = makeSession({ metadata: { claudeSessionUuid: "uuid-that-doesnt-exist" } }); + const result = await agent.getActivityState(session); + expect(result?.state).toBe("active"); + }); + + it("resolves symlinked workspace paths so slugs match what Claude wrote", async () => { + // Claude resolves the symlink target before slugifying. If AO records + // the symlink path and slugifies without realpath, the two slugs + // diverge and findLatestSessionFile returns null forever. This test + // confirms the realpath fix: write JSONL under the SLUG OF THE TARGET, + // call getActivityState with the SYMLINK PATH, expect to find it. + const { symlinkSync } = await import("node:fs"); + const target = workspacePath; // the real workspace dir + const link = join(fakeHome, "symlinked-workspace"); + symlinkSync(target, link); + + // JSONL is already in projectDir (which slugifies the real path). + writeJsonl([{ type: "assistant", message: { content: "Done!" } }]); + + // Calling with the SYMLINK should still find the file because of realpath. + const result = await agent.getActivityState(makeSession({ workspacePath: link })); + expect(result?.state).toBe("ready"); + }); + + it("returns null when project directory does not exist and AO activity is unavailable", async () => { const badPath = join(fakeHome, "nonexistent-workspace"); - expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle"); + expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull(); + }); + + it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => { + await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + + const result = await readLastActivityEntry(workspacePath); + expect(result?.entry.state).toBe("waiting_input"); + expect(result?.entry.source).toBe("terminal"); + expect(result?.entry.trigger).toContain("Do you want to proceed?"); + }); + + it("recordActivity is a no-op when workspacePath is null", async () => { + await agent.recordActivity?.( + makeSession({ workspacePath: null }), + "Do you want to proceed?\n(Y)es / (N)o", + ); + + expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false); + }); + + it("keeps native JSONL as primary when AO activity JSONL also exists", async () => { + writeJsonl([{ type: "assistant", message: { content: "Done!" } }]); + writeActivityLog("waiting_input"); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => { + await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); + }); + + it("falls back to AO JSONL waiting_input when native session entry predates this session", async () => { + writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); + const session = makeSession({ createdAt: new Date() }); + + await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o"); + + expect((await agent.getActivityState(session))?.state).toBe("waiting_input"); + }); + + it("returns idle for stale native session entry when AO JSONL is unavailable", async () => { + writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); + const session = makeSession({ createdAt: new Date() }); + + const result = await agent.getActivityState(session); + + expect(result?.state).toBe("idle"); + expect(result?.timestamp).toBe(session.createdAt); + }); + + it("falls back to AO JSONL age-decay when native session lookup is unavailable", async () => { + writeActivityLog("active", 400_000); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); // ----------------------------------------------------------------------- @@ -185,19 +369,99 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("returns 'active' for recent 'file-history-snapshot' (bookkeeping)", async () => { + it("returns 'blocked' for 'system' api_error (level: error)", async () => { + writeJsonl([ + { + type: "system", + subtype: "api_error", + level: "error", + cause: { code: "ConnectionRefused" }, + }, + ]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("blocked"); + }); + + it("returns 'ready' for non-error 'system' subtypes (compact_boundary)", async () => { + writeJsonl([{ type: "system", subtype: "compact_boundary", level: "info" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + it("requires BOTH api_error subtype AND error level for 'blocked'", async () => { + // A future error-level diagnostic that isn't api_error must NOT be + // silently classified as blocked. + writeJsonl([{ type: "system", subtype: "future_diagnostic", level: "error" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + // Bookkeeping types Claude writes AFTER finishing a turn — these are + // turn-end markers, not "Claude is working" signals. They must map to + // ready/idle by age, NOT active. Previously they fell through to the + // default branch and looked "active" for 30s; fixed in this PR. + it("returns 'ready' for recent 'file-history-snapshot' (bookkeeping)", async () => { writeJsonl([{ type: "file-history-snapshot" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("returns 'active' for recent 'queue-operation' (bookkeeping)", async () => { + it("returns 'ready' for recent 'queue-operation' (bookkeeping)", async () => { writeJsonl([{ type: "queue-operation" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("returns 'active' for recent 'pr-link' (bookkeeping)", async () => { - writeJsonl([{ type: "pr-link" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); + it("returns 'idle' (not 'ready') for recent 'pr-link' — re-snapshot noise", async () => { + // Empirical evidence (ao-160): the SAME PR (#1911) was written as + // pr-link 33 times in the last 200 lines, with new timestamps each + // time. It's a periodic state snapshot, not a one-shot event. + // Treat as noise so dormant sessions don't show as "ready" forever. + writeJsonl([ + { + type: "pr-link", + prNumber: 1911, + prUrl: "https://github.com/owner/repo/pull/1911", + }, + ]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + }); + + it("returns 'ready' for recent 'attachment' (bookkeeping)", async () => { + writeJsonl([{ type: "attachment", attachment: { type: "skill_listing" } }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + // Pure UI-noise types (permission-mode, ai-title, agent-*, custom-title) + // are written by Claude at random times (session attach, mode change, + // title gen) and don't reflect actual activity. Real-world repro: + // ao-144 had 73 trailing permission-mode + 73 trailing ai-title entries + // over 6 dormant days, causing the dashboard to oscillate between + // ready and idle. They now fall through to the AO JSONL pipeline; if + // that has nothing, the stale-native fallback returns idle from + // session.createdAt. + it("returns 'idle' (not 'ready') for recent permission-mode noise — dormant session", async () => { + writeJsonl([{ type: "permission-mode", permissionMode: "bypassPermissions" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + }); + + it("returns 'idle' (not 'ready') for recent ai-title noise — dormant session", async () => { + writeJsonl([{ type: "ai-title", title: "Fix login bug" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + }); + + it("returns 'idle' for agent-color / agent-name / custom-title noise", async () => { + writeJsonl([{ type: "agent-color", color: "#fff" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + writeJsonl([{ type: "agent-name", name: "ao-161" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + writeJsonl([{ type: "custom-title", title: "x" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + }); + + it("noise last entry yields to AO JSONL when AO has actionable state", async () => { + // Repro of the scenario where Claude IS active but the latest native + // JSONL line happens to be noise. Native JSONL gets skipped, AO + // activity JSONL has waiting_input from a recent terminal scrape, + // cascade returns waiting_input. + writeJsonl([{ type: "permission-mode" }]); + writeActivityLog("waiting_input"); + expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); }); }); @@ -206,29 +470,19 @@ describe("Claude Code Activity Detection", () => { // ----------------------------------------------------------------------- describe("agent interface spec types", () => { - it("returns 'active' for recent 'tool_use' entry", async () => { - writeJsonl([{ type: "tool_use" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); - }); - - it("returns 'waiting_input' for 'permission_request'", async () => { - writeJsonl([{ type: "permission_request" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); - }); - - it("returns 'blocked' for 'error'", async () => { - writeJsonl([{ type: "error" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("blocked"); - }); - it("returns 'ready' for recent 'summary' entry", async () => { writeJsonl([{ type: "summary", summary: "Implemented login feature" }]); expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("returns 'ready' for recent 'result' entry", async () => { - writeJsonl([{ type: "result" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + it("unknown types fall through to default branch — fresh → active", async () => { + // Claude doesn't emit `tool_use` or `result` as top-level types (verified + // on disk for #1927). Their explicit switch cases were removed and they + // now fall to `default`, which maps fresh unknown types to active. This + // test locks the default-branch semantics so future Claude type + // additions are handled predictably until someone adds an explicit case. + writeJsonl([{ type: "some-future-claude-type" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("active"); }); }); @@ -257,13 +511,11 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); - it("'permission_request' ignores staleness (always waiting_input)", async () => { - writeJsonl([{ type: "permission_request" }], 400_000); - expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); - }); - - it("'error' ignores staleness (always blocked)", async () => { - writeJsonl([{ type: "error" }], 400_000); + it("'system' api_error ignores staleness (always blocked)", async () => { + writeJsonl( + [{ type: "system", subtype: "api_error", level: "error" }], + 400_000, + ); expect((await agent.getActivityState(makeSession()))?.state).toBe("blocked"); }); @@ -306,8 +558,8 @@ describe("Claude Code Activity Detection", () => { it("ignores agent- prefixed JSONL files", async () => { writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl"); - // No real session file → process is running, treat as idle - expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + // No real session file and no AO activity fallback. + expect(await agent.getActivityState(makeSession())).toBeNull(); }); it("reads last entry from multi-entry JSONL (not first)", async () => { diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts new file mode 100644 index 000000000..940a4591f --- /dev/null +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -0,0 +1,530 @@ +import { + readLastJsonlEntry, + readLastActivityEntry, + checkActivityLogState, + getActivityFallbackState, + isWindows, + PROCESS_PROBE_INDETERMINATE, + DEFAULT_READY_THRESHOLD_MS, + DEFAULT_ACTIVE_WINDOW_MS, + type ActivityDetection, + type ActivityState, + type ProcessProbeResult, + type RuntimeHandle, + type Session, +} from "@aoagents/ao-core"; +import { execFile } from "node:child_process"; +import { readdir, realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// ============================================================================= +// Project-path slug +// ============================================================================= + +/** + * Convert a workspace path to Claude's project directory path. + * Claude stores sessions at ~/.claude/projects/{encoded-path}/ + * + * Verified against Claude Code's actual on-disk slugs: every non-alphanumeric + * character (other than `-`) is replaced with `-`. That includes `/`, `.`, + * `:`, and crucially `_` — AO's per-project data dirs are named like + * `_`, and without underscore folding the slug AO computes + * misses the directory Claude actually wrote (issue #1611). + * + * Windows: `C:\Users\dev\project` → `C--Users-dev-project` — Claude leaves the + * colon-position as a dash rather than stripping it. Verified via on-disk QA + * during the Windows port (commit 582c5373). Stripping the colon (as #1611 + * inadvertently did) breaks JSONL lookup on Windows. + */ +export function toClaudeProjectPath(workspacePath: string): string { + const normalized = workspacePath.replace(/\\/g, "/"); + return normalized.replace(/[^a-zA-Z0-9-]/g, "-"); +} + +/** + * Resolve a workspace path through any symlinks BEFORE slugifying so AO's + * computed Claude project dir matches what Claude itself writes. + * + * Without this, if AO records `workspacePath` as a symlink (e.g. + * `/Users/me/symlinks/repo`) and Claude resolves it to the target + * (`/Users/me/code/repo`) before computing its on-disk slug, the two slugs + * diverge — AO looks in an empty `~/.claude/projects//` dir + * forever and the session looks permanently `idle`. Falls back to the + * literal path on error (dangling symlink, race, etc.). + */ +export async function resolveWorkspaceForClaude(workspacePath: string): Promise { + try { + return await realpath(workspacePath); + } catch { + return workspacePath; + } +} + +// ============================================================================= +// Session file discovery +// ============================================================================= + +/** Module-level dedupe so EACCES/EPERM on a project dir warns ONCE per path + * for the process lifetime, not on every poll cycle. getClaudeActivityState + * is called every few seconds per session — without this, a single denied + * path would flood logs at 60+ lines/minute indefinitely. Bounded by the + * number of unique workspace slugs, which is small. */ +const warnedReaddirPaths = new Set(); + +/** Reset the warned-paths dedupe set. Exported for testing only. */ +export function resetWarnedReaddirPaths(): void { + warnedReaddirPaths.clear(); +} + +/** Find Claude's JSONL session file for a project directory. + * + * When `preferredUuid` is provided (e.g. from `session.metadata.claudeSessionUuid` + * captured by getSessionInfo), prefer `/.jsonl` + * if it exists. This disambiguates the common case of multiple Claude + * sessions running in the same workspace, where newest-mtime would pick + * the WRONG session's JSONL whenever its sibling has just written. + * + * Falls back to newest-mtime when no UUID is given or the named file + * doesn't exist yet (e.g. fresh session that hasn't been introspected). + * + * ENOENT on the project dir is normal and silent. Other errors + * (EACCES, EPERM, EMFILE, ...) are logged via console.warn — once per + * path for the process lifetime — so a permission-denied or fd-exhausted + * misconfig doesn't silently mask the session as `idle` forever and + * doesn't flood logs on every poll. */ +export async function findLatestSessionFile( + projectDir: string, + preferredUuid?: string, +): Promise { + // Prefer the UUID-named file when we know it — disambiguates multi-session. + if (preferredUuid) { + const preferred = join(projectDir, `${preferredUuid}.jsonl`); + try { + await stat(preferred); + return preferred; + } catch { + // Fall through to newest-mtime — the UUID-named file may not exist + // yet (session just spawned, hasn't been introspected). + } + } + + let entries: string[]; + try { + entries = await readdir(projectDir); + } catch (err: unknown) { + if (err instanceof Error && "code" in err && err.code !== "ENOENT") { + if (!warnedReaddirPaths.has(projectDir)) { + warnedReaddirPaths.add(projectDir); + const code = (err as NodeJS.ErrnoException).code; + console.warn( + `[claude-code] failed to read ${projectDir} (${code}): ${err.message}. Session activity will fall back to AO JSONL only. (This warning is shown once per path for the process lifetime.)`, + ); + } + } + return null; + } + + const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); + if (jsonlFiles.length === 0) return null; + + const withStats = await Promise.all( + jsonlFiles.map(async (f) => { + const fullPath = join(projectDir, f); + try { + const s = await stat(fullPath); + return { path: fullPath, mtime: s.mtimeMs }; + } catch { + return { path: fullPath, mtime: 0 }; + } + }), + ); + withStats.sort((a, b) => b.mtime - a.mtime); + return withStats[0]?.path ?? null; +} + +// ============================================================================= +// Process detection +// ============================================================================= + +/** + * TTL cache for `ps -eo pid,tty,args` output. Without this, listing N sessions + * would spawn N concurrent `ps` processes, each taking 30+ seconds on machines + * with many processes. The cache ensures `ps` is called at most once per TTL + * window regardless of how many sessions are being enriched. + */ +type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE; +let psCache: { + output: ProcessListResult; + timestamp: number; + promise?: Promise; +} | null = null; +const PS_CACHE_TTL_MS = 5_000; + +/** Reset the ps cache. Exported for testing only. */ +export function resetPsCache(): void { + psCache = null; +} + +async function getCachedProcessList(): Promise { + // ps -eo is a Unix-only command; on Windows the tmux branch is never taken + // in normal operation, but guard here to avoid a spurious spawn error if + // a stale tmux handle is encountered. + if (isWindows()) return ""; + const now = Date.now(); + if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) { + if (psCache.promise) return psCache.promise; + return psCache.output; + } + + const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }) + .then(({ stdout }) => { + if (psCache?.promise === promise) { + psCache = { output: stdout || PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return stdout || PROCESS_PROBE_INDETERMINATE; + }) + .catch(() => { + if (psCache?.promise === promise) { + psCache = { output: PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; + } + return PROCESS_PROBE_INDETERMINATE; + }); + + psCache = { output: "", timestamp: now, promise }; + + return promise; +} + +/** + * Check if a process named "claude" is running in the given runtime handle's context. + * Uses ps to find processes by TTY (for tmux) or by PID. + */ +export async function findClaudeProcess( + handle: RuntimeHandle, +): Promise { + try { + if (handle.runtimeName === "tmux" && handle.id) { + if (isWindows()) return null; + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); + const ttys = ttyOut + .trim() + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + if (ttys.length === 0) return null; + + const psOut = await getCachedProcessList(); + if (psOut === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; + + const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); + // Match "claude" plus common variants: + // - bare `claude` / `/usr/local/bin/claude` + // - dot-prefix shim `.claude` + // - file extensions like `claude.exe`, `claude.js`, `claude.cjs` + // - hyphenated names like `claude-code` + // - node-shim cases like `node /path/@anthropic-ai/claude-code/cli.js` + // (matches the path component containing "claude") + // Still anchored at `/` or start-of-line so `claudia` etc. don't match. + const processRe = /(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)/; + for (const line of psOut.split("\n")) { + const cols = line.trimStart().split(/\s+/); + if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; + const args = cols.slice(2).join(" "); + if (processRe.test(args)) { + return parseInt(cols[0] ?? "0", 10); + } + } + return null; + } + + // For process runtime, check if the PID stored in handle data is alive + const rawPid = handle.data["pid"]; + const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 0); + return pid; + } catch (err: unknown) { + // EPERM means the process exists but we lack permission to signal it + if (err instanceof Error && "code" in err && err.code === "EPERM") { + return pid; + } + return null; + } + } + + return null; + } catch { + return PROCESS_PROBE_INDETERMINATE; + } +} + +export async function isClaudeProcessAlive(handle: RuntimeHandle): Promise { + const pid = await findClaudeProcess(handle); + if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; + return pid !== null; +} + +// ============================================================================= +// Terminal output classification +// ============================================================================= + +/** Classify Claude Code's activity state from terminal output (pure, sync). */ +export function classifyTerminalOutput(terminalOutput: string): ActivityState { + if (!terminalOutput.trim()) return "idle"; + + const lines = terminalOutput.trim().split("\n"); + const lastLine = lines[lines.length - 1]?.trim() ?? ""; + + // Empty prompt on the last line is unambiguously idle. + if (/^[❯>$#]\s*$/.test(lastLine)) return "idle"; + + // Use a wider window (last 12 lines) than the bottom-of-buffer prompt + // check above because Claude's spinner+status line, ⎿ tool-result lines, + // and api-error text often sit 6-8 lines above the input area + footer. + // All multi-line state checks (blocked, active) use this window — full + // `terminalOutput` would let scrolled-off error text falsely return + // "blocked" forever after a successful retry pushes the error out of + // view but not out of scrollback. + const wideTail = lines.slice(-12).join("\n"); + + // Check for blocked. Claude's persistent UI footer contains + // "bypass permissions on (shift+tab to cycle)" on every session — the + // tightened waiting_input regex no longer matches it, and the blocked + // patterns below are specific to api-error retry text. + // + // Patterns observed empirically by capturing tmux output during a real + // api-blocked retry loop (see PR #1932 description): + // ⎿ Unable to connect to API (ConnectionRefused) + // Retrying in 19s · attempt 7/10 + if (/Unable to connect to API/i.test(wideTail)) return "blocked"; + if (/Retrying in \d+s.*attempt \d+\/\d+/i.test(wideTail)) return "blocked"; + + // Check the bottom of the buffer for permission prompts. Historical + // "Thinking"/"Reading" text earlier in the buffer must not override a + // current permission prompt at the bottom. + const tail = lines.slice(-5).join("\n"); + if (/Do you want to proceed\?/i.test(tail)) return "waiting_input"; + if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input"; + // Match the ACTUAL permission-bypass prompt, NOT the static footer toggle. + if (/bypass\s+all\s+future\s+permissions/i.test(tail)) return "waiting_input"; + + // Active: only when an explicit active-work indicator is present. Default + // is IDLE — Claude's tmux pane has a persistent input area + footer that + // looks identical between "just finished" and "working". Treating + // unrecognized output as active caused dormant sessions (ao-160 etc.) to + // get an "active" written to AO activity-JSONL every poll cycle, which the + // age-decayed fallback then surfaced as ready forever. + + // Strongest active signal: gerund (present-participle) status verb + // followed by the trailing ellipsis "…". Claude cycles through many + // status words (Germinating, Fluttering, Pondering, Mulling, Crafting, + // Thinking, Reasoning, ...) and many spinner glyphs (✻ ✽ · ⠁ ⠈ etc.) + // depending on animation frame. The gerund+ellipsis combo is the + // consistent signal that survives glyph rotation. Past-tense lines like + // "✻ Worked for 11s" or "✻ Crunched for 11s" lack the ellipsis and are + // turn-complete summaries — they must NOT match (ao-143 repro). + if (/\b\w+ing…/.test(wideTail)) return "active"; + // NOTE — these patterns look "active-ish" but are NOT, and should never + // go back as active indicators: + // - /\bCrunched\s+for\s+\d+s/ — past-tense turn summary (ao-154 repro: + // "✻ Crunched for 22s" was making sessions perpetually "active") + // - /\bWorked\s+for\s+\d+s/ — past-tense turn summary (ao-143 repro) + // - /^\s*⎿\s+/ — prefixes past tool-results too + // - /esc to interrupt/ — in the persistent UI footer + // - bare /\bWorking\b/ — matches "working on issue #N" in recap + // The gerund+ellipsis above catches present-tense forms across all + // status words regardless of spinner glyph rotation. + + // Word-based fallbacks for synthetic test inputs and rare cases where + // the ellipsis isn't captured. Tight patterns to avoid false-firing on + // benign text. + if (/\bGerminating/i.test(wideTail)) return "active"; + if (/\b(?:Thinking|Working)\s*(?:…|\.\.\.)/i.test(wideTail)) return "active"; + if (/\bReading\s+file/i.test(wideTail)) return "active"; + if (/\bWriting\s+to\b/i.test(wideTail)) return "active"; + if (/\bSearching\s+codebase/i.test(wideTail)) return "active"; + if (/Press\s+up\s+to\s+edit\s+queued/i.test(wideTail)) return "active"; + + return "idle"; +} + +// ============================================================================= +// Activity-state cascade +// ============================================================================= + +/** + * Claude writes these types as UI-state snapshots at random times: on session + * attach, on permission-mode change, on title regeneration, etc. They are + * NOT correlated with whether Claude is actively working — a 6-day-dormant + * session will still accumulate dozens of `permission-mode` and `ai-title` + * entries just from being inspected. + * + * When one of these is the literal last JSONL entry, treat it as a "no + * signal" — fall through to the AO activity-JSONL pipeline (terminal- + * derived signal) rather than letting noise mtime decide the activity. + * + * Concrete bug this prevents: ao-144 had 73 trailing `permission-mode` + + * 73 trailing `ai-title` entries written over 6 dormant days. Without + * this skip, dashboard oscillated between `ready` (recent noise mtime) + * and `idle` (old noise mtime) instead of staying `idle`. + * + * Conservative list — only the types that empirically run away. The other + * bookkeeping types (file-history-snapshot, attachment, pr-link, + * queue-operation, last-prompt) plausibly correlate with real activity + * and stay in the explicit ready/idle case. + */ +const NOISE_JSONL_TYPES: ReadonlySet = new Set([ + "permission-mode", + "ai-title", + "agent-color", + "agent-name", + "custom-title", + // pr-link is also re-snapshot noise — verified on ao-160's JSONL where the + // SAME PR (#1911) was written as a `pr-link` entry three times within + // minutes (count: 33 pr-link vs 21 user messages in the last 200 lines). + // The first emission is real; subsequent re-emissions are state snapshots. + // We can't distinguish first vs Nth from the last line alone, so treat + // all pr-link as noise. Real PR creation is still observable via the + // assistant message and the gh-tracker side. + "pr-link", +]); + +/** + * Determine current activity state for a Claude Code session. + * + * Cascade: + * 1. Process check (returns null on INDETERMINATE, exited on dead) + * 2. Native JSONL: read last entry, map type+mtime → state + * 3. AO activity JSONL: `checkActivityLogState` for actionable states + * (waiting_input/blocked) terminal regex picked up + * 4. AO activity JSONL: `getActivityFallbackState` for age-decayed fallback + * 5. Stale native (entry predates session) returned only if nothing else + * + * Note: Claude does NOT emit `permission_request` or top-level `error` + * as JSONL types. `waiting_input` flows through the terminal regex → + * AO activity JSONL path. `blocked` is detected from native JSONL via + * `{type:"system", level:"error"}` (Claude's api_error shape). + */ +export async function getClaudeActivityState( + session: Session, + readyThresholdMs: number | undefined, + isProcessAlive: (handle: RuntimeHandle) => Promise = isClaudeProcessAlive, +): Promise { + const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; + + const exitedAt = new Date(); + if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; + const running = await isProcessAlive(session.runtimeHandle); + if (running === PROCESS_PROBE_INDETERMINATE) return null; + if (!running) return { state: "exited", timestamp: exitedAt }; + + if (!session.workspacePath) return null; + + const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath)); + const projectDir = join(homedir(), ".claude", "projects", projectPath); + + // Prefer the UUID-named file when getSessionInfo has captured one — this + // disambiguates multi-session-per-worktree, where newest-mtime would pick + // the wrong session's JSONL whenever its sibling has just written. + const rawUuid = session.metadata?.["claudeSessionUuid"]; + const preferredUuid = + typeof rawUuid === "string" && rawUuid.trim() ? rawUuid.trim() : undefined; + const sessionFile = await findLatestSessionFile(projectDir, preferredUuid); + let staleNativeState: ActivityDetection | null = null; + if (sessionFile) { + const entry = await readLastJsonlEntry(sessionFile); + if (entry) { + // If the JSONL entry predates this session, it's from a previous session + // in the same worktree. Fall through to the AO safety net first: the + // terminal may have already surfaced waiting_input/blocked before + // Claude writes this session's first native JSONL entry. + if (session.createdAt && entry.modifiedAt < session.createdAt) { + staleNativeState = { state: "idle", timestamp: session.createdAt }; + } else if (entry.lastType && NOISE_JSONL_TYPES.has(entry.lastType)) { + // Last entry is UI-state noise (permission-mode / ai-title / etc.) + // that doesn't reflect actual activity. Fall through to the AO + // activity-JSONL pipeline for a terminal-derived answer; if that's + // also empty, the staleNativeState below returns idle. + staleNativeState = { state: "idle", timestamp: session.createdAt }; + } else { + const ageMs = Date.now() - entry.modifiedAt.getTime(); + const timestamp = entry.modifiedAt; + + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + switch (entry.lastType) { + // In-progress turn markers: very recent → active, older → ready/idle. + // Removed `tool_use` and `result` cases that were in the spec but + // never actually emitted by Claude (verified by disk survey for + // #1927). The `default` branch handles them with the same semantics + // if Claude ever introduces them. + case "user": + case "progress": + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "system": + // Claude writes API errors as `{type:"system", subtype:"api_error", + // level:"error", cause:{...}}`. Require BOTH the subtype AND the + // level so a future error-level diagnostic that isn't actually + // fatal doesn't get silently classified as blocked. Other system + // subtypes (compact_boundary, local_command, turn_duration, etc.) + // are normal turn-end markers. + if (entry.lastSubtype === "api_error" && entry.lastLevel === "error") { + return { state: "blocked", timestamp }; + } + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "assistant": + case "summary": + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + // Bookkeeping types Claude writes AFTER a real event (file edits, + // attachment context, queue housekeeping, prompt submit). Map to + // ready/idle by age, same as assistant/summary. The pure re-snapshot + // types (permission-mode, ai-title, agent-*, custom-title, pr-link) + // are filtered out earlier by NOISE_JSONL_TYPES — they get written + // continuously without indicating activity. + case "file-history-snapshot": + case "attachment": + case "queue-operation": + case "last-prompt": + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + default: + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + } + } + } + // Session file exists but no parseable entry — fall through to AO JSONL + // checks below instead of returning early, so terminal-derived + // waiting_input/blocked can still be detected. + } + + // Fallback: check AO activity JSONL (terminal-derived) for + // waiting_input/blocked when Claude's native JSONL is unavailable. + const activityResult = await readLastActivityEntry(session.workspacePath); + const activityState = checkActivityLogState(activityResult); + if (activityState) return activityState; + + // Last fallback: use the AO entry with age-based decay when native + // session lookup is missing or unparseable (e.g. Claude project slug drift). + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (fallback) return fallback; + + if (staleNativeState) return staleNativeState; + + return null; +} diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index e664509a0..be8955c55 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -375,6 +375,45 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); }); + // Coverage for the broadened process regex — these are real install shapes + // the previous narrow regex `/(?:^|\/)claude(?:\s|$)/` would have missed, + // causing AO to declare sessions `exited` while Claude was still running. + it.each([ + ["bare binary", "claude"], + ["absolute path", "/opt/homebrew/bin/claude"], + ["dot-prefix shim", "/usr/local/lib/.claude"], + ["windows exe", "claude.exe"], + ["js shim", "claude.js"], + ["hyphenated name", "claude-code"], + ["node-shim npm install", "node /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js"], + ])("returns true for %s (%s)", async (_label, args) => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" }); + if (cmd === "ps") + return Promise.resolve({ + stdout: ` PID TT ARGS\n 123 ttys001 ${args}\n`, + stderr: "", + }); + return Promise.reject(new Error("unexpected")); + }); + resetPsCache(); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); + }); + + it("still rejects look-alike names (claudia, claudine)", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" }); + if (cmd === "ps") + return Promise.resolve({ + stdout: " PID TT ARGS\n 123 ttys001 claudia\n 124 ttys001 /bin/claudine\n", + stderr: "", + }); + return Promise.reject(new Error("unexpected")); + }); + resetPsCache(); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + }); + it("returns false when tmux list-panes returns empty", async () => { mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); @@ -441,22 +480,6 @@ describe("isProcessRunning", () => { expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); }); - it("does not match similar process names like claude-code", async () => { - mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => { - if (cmd === "tmux" && args[0] === "list-panes") { - return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" }); - } - if (cmd === "ps") { - return Promise.resolve({ - stdout: " PID TT ARGS\n 100 ttys001 /usr/bin/claude-code\n", - stderr: "", - }); - } - return Promise.reject(new Error("unexpected")); - }); - expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); - }); - it("returns false for tmux handle on Windows without spawning ps", async () => { mockIsWindows.mockReturnValue(true); // ps should never be called — getCachedProcessList guards against Windows @@ -515,6 +538,23 @@ describe("detectActivity", () => { ); }); + it("does NOT match Claude's persistent UI footer 'bypass permissions on (shift+tab to cycle)'", () => { + // Regression test: the old `/bypass.*permissions/i` regex matched this + // footer toggle (visible on EVERY Claude session) and falsely fired + // waiting_input for every session that fell through to the AO JSONL + // pipeline. ao-143/144/151 all flipped to waiting_input on dormant + // sessions until this was tightened to require "all future". + const footerOnly = [ + "✻ Crunched for 11s", + "", + "──────────────────────────────────────────────────────────", + "❯ ", + "──────────────────────────────────────────────────────────", + " ⏵⏵ bypass permissions on (shift+tab to cycle)", + ].join("\n"); + expect(agent.detectActivity(footerOnly)).not.toBe("waiting_input"); + }); + it("returns active when queued message indicator is visible", () => { expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active"); }); @@ -543,8 +583,131 @@ describe("detectActivity", () => { ).toBe("waiting_input"); }); - it("returns active for non-empty output with no special patterns", () => { - expect(agent.detectActivity("some random terminal output\n")).toBe("active"); + it("returns idle for non-empty output with no active-work indicators", () => { + // Default-to-idle (changed from default-to-active in this PR). Claude's + // tmux pane has a persistent input area + footer that looks identical + // between "just finished" and "currently working". Treating + // unrecognized output as active caused dormant sessions to get an + // "active" written to AO activity-JSONL every poll cycle, which the + // age-decayed fallback then surfaced as ready forever (ao-160 repro). + expect(agent.detectActivity("some random terminal output\n")).toBe("idle"); + }); + + it("returns idle for dormant session showing only Claude's input area + footer", () => { + // Real captured output from a dormant session (ao-143 style): assistant + // output above, separator, empty prompt line, separator, footer toggle. + // The empty prompt ❯ is NOT the LAST line (footer is) so the existing + // lastLine check misses it, and previously the default-to-active sent + // every dormant session into the AO-JSONL active-loop. + const dormant = [ + "※ recap: working on issue #143; next: wait for review", + "", + "──────────────────────────────────────────────────────────", + "❯ ", + "──────────────────────────────────────────────────────────", + " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", + ].join("\n"); + expect(agent.detectActivity(dormant)).toBe("idle"); + }); + + it("returns active when spinner+ellipsis is in the tail (✻ Fluttering…)", () => { + // Real captured output from ao-161 mid-active-turn. The ✻ spinner + // followed by a verb and trailing ellipsis is the canonical Claude + // active indicator across all turn-status words (Germinating, + // Fluttering, Thinking, Pondering, etc). + const active = [ + "✻ Fluttering… (6m 49s · ↓ 26.9k tokens)", + " ⎿ Tip: Use /feedback to help us improve!", + "", + "──────", + "❯ ", + "──────", + " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", + ].join("\n"); + expect(agent.detectActivity(active)).toBe("active"); + }); + + it("returns idle for past-tense spinner status like '✻ Worked for 11s' (no ellipsis)", () => { + // Real captured output from ao-143 dormant. The ✻ glyph appears in + // past-tense turn summaries too — without the trailing ellipsis, + // Claude is done, not active. + const dormant = [ + "⏺ Posted: https://github.com/owner/repo/pull/1#comment-1", + "", + "✻ Worked for 11s", + "", + "※ recap: working on issue #143; next: wait for review", + "──────", + "❯ ", + "──────", + " ⏵⏵ bypass permissions on (shift+tab to cycle)", + ].join("\n"); + expect(agent.detectActivity(dormant)).toBe("idle"); + }); + + // Blocked detection from terminal regex — empirically captured from real + // Claude output during api.anthropic.com block (see PR #1932). + it("returns blocked for 'Unable to connect to API' error line", () => { + const real = [ + "❯ what is 2+2? answer in one word.", + " ⎿ Unable to connect to API (ConnectionRefused)", + " Retrying in 19s · attempt 7/10", + "", + "✽ Germinating… (56s)", + "", + ].join("\n"); + expect(agent.detectActivity(real)).toBe("blocked"); + }); + + it("returns blocked for FailedToOpenSocket error variant", () => { + expect( + agent.detectActivity(" ⎿ Unable to connect to API (FailedToOpenSocket)\n"), + ).toBe("blocked"); + }); + + it("returns blocked for retry counter alone (Retrying in Ns · attempt N/M)", () => { + // If only the retry line is in the visible window (error scrolled off), + // the retry counter is still a sufficient signal. + expect(agent.detectActivity(" Retrying in 30s · attempt 9/10\n")).toBe("blocked"); + }); + + it("does NOT return blocked when API error has scrolled out of the visible window after a successful retry", () => { + // Regression test: blocked detection must be bounded to the last 12 + // lines (wideTail), NOT the full terminalOutput buffer. Otherwise an + // api_error that scrolled off the visible area after a successful + // retry but stayed in scrollback would falsely return "blocked" + // forever (Greptile review on PR #1932). + const recoveredAndContinued = [ + " ⎿ Unable to connect to API (ConnectionRefused)", + " Retrying in 1s · attempt 1/10", + " ⎿ ✓ Connected, retry succeeded", + "", + "(many lines of work output below pushing the error off the visible area)", + ...Array.from({ length: 15 }, (_, i) => ` line ${i + 1} of subsequent work`), + "", + "✻ Fluttering… (2m 14s)", + " ⎿ Tip: Use /feedback to help us improve!", + "", + "──────", + "❯ ", + "──────", + " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", + ].join("\n"); + expect(agent.detectActivity(recoveredAndContinued)).toBe("active"); + }); + + it("blocked takes precedence over waiting_input when both 'bypass permissions' footer and api-error are present", () => { + // Claude's static UI footer always contains "bypass permissions on …", + // which the existing waiting_input regex matches. A real blocked state + // must win over that incidental match. + const real = [ + " ⎿ Unable to connect to API (ConnectionRefused)", + " Retrying in 1s · attempt 5/10", + "", + "────────────────────────────────────────", + " ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt", + ].join("\n"); + expect(agent.detectActivity(real)).toBe("blocked"); }); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 1e5a933ac..29e5ce5e3 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -1,11 +1,8 @@ import { shellEscape, - readLastJsonlEntry, normalizeAgentPermissionMode, isWindows, - PROCESS_PROBE_INDETERMINATE, - DEFAULT_READY_THRESHOLD_MS, - DEFAULT_ACTIVE_WINDOW_MS, + recordTerminalActivity, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -19,14 +16,21 @@ import { type Session, type WorkspaceHooksConfig, } from "@aoagents/ao-core"; -import { execFile, execFileSync } from "node:child_process"; -import { readdir, readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; -import { promisify } from "node:util"; +import { + classifyTerminalOutput, + findLatestSessionFile, + getClaudeActivityState, + isClaudeProcessAlive, + resolveWorkspaceForClaude, + toClaudeProjectPath, +} from "./activity-detection.js"; -const execFileAsync = promisify(execFile); +export { resetPsCache, resolveWorkspaceForClaude, toClaudeProjectPath } from "./activity-detection.js"; // ============================================================================= // Metadata Updater Hook Script @@ -397,56 +401,6 @@ export const manifest = { // JSONL Helpers // ============================================================================= -/** - * Convert a workspace path to Claude's project directory path. - * Claude stores sessions at ~/.claude/projects/{encoded-path}/ - * - * Verified against Claude Code's actual on-disk slugs: every non-alphanumeric - * character (other than `-`) is replaced with `-`. That includes `/`, `.`, - * `:`, and crucially `_` — AO's per-project data dirs are named like - * `_`, and without underscore folding the slug AO computes - * misses the directory Claude actually wrote (issue #1611). - * - * Windows: `C:\Users\dev\project` → `C--Users-dev-project` — Claude leaves the - * colon-position as a dash rather than stripping it. Verified via on-disk QA - * during the Windows port (commit 582c5373). Stripping the colon (as #1611 - * inadvertently did) breaks JSONL lookup on Windows. - * - * Exported for testing purposes. - */ -export function toClaudeProjectPath(workspacePath: string): string { - const normalized = workspacePath.replace(/\\/g, "/"); - return normalized.replace(/[^a-zA-Z0-9-]/g, "-"); -} - -/** Find the most recently modified .jsonl session file in a directory */ -async function findLatestSessionFile(projectDir: string): Promise { - let entries: string[]; - try { - entries = await readdir(projectDir); - } catch { - return null; - } - - const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); - if (jsonlFiles.length === 0) return null; - - // Sort by mtime descending - const withStats = await Promise.all( - jsonlFiles.map(async (f) => { - const fullPath = join(projectDir, f); - try { - const s = await stat(fullPath); - return { path: fullPath, mtime: s.mtimeMs }; - } catch { - return { path: fullPath, mtime: 0 }; - } - }), - ); - withStats.sort((a, b) => b.mtime - a.mtime); - return withStats[0]?.path ?? null; -} - interface JsonlLine { type?: string; summary?: string; @@ -608,163 +562,6 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined { }; } -// ============================================================================= -// Process Detection -// ============================================================================= - -/** - * TTL cache for `ps -eo pid,tty,args` output. Without this, listing N sessions - * would spawn N concurrent `ps` processes, each taking 30+ seconds on machines - * with many processes. The cache ensures `ps` is called at most once per TTL - * window regardless of how many sessions are being enriched. - */ -type ProcessListResult = string | typeof PROCESS_PROBE_INDETERMINATE; -let psCache: { - output: ProcessListResult; - timestamp: number; - promise?: Promise; -} | null = null; -const PS_CACHE_TTL_MS = 5_000; - -/** Reset the ps cache. Exported for testing only. */ -export function resetPsCache(): void { - psCache = null; -} - -async function getCachedProcessList(): Promise { - // ps -eo is a Unix-only command; on Windows the tmux branch is never taken - // in normal operation, but guard here to avoid a spurious spawn error if - // a stale tmux handle is encountered. - if (isWindows()) return ""; - const now = Date.now(); - if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) { - // Cache hit — return resolved output or wait for in-flight request - if (psCache.promise) return psCache.promise; - return psCache.output; - } - - // Cache miss or expired — start a single `ps` call and share the promise. - // Guard both callbacks so they only update psCache if it still belongs to - // this request — a newer request may have replaced it while we were waiting. - const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], { - timeout: 30_000, - }) - .then(({ stdout }) => { - if (psCache?.promise === promise) { - psCache = { output: stdout || PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; - } - return stdout || PROCESS_PROBE_INDETERMINATE; - }) - .catch(() => { - if (psCache?.promise === promise) { - psCache = { output: PROCESS_PROBE_INDETERMINATE, timestamp: Date.now() }; - } - return PROCESS_PROBE_INDETERMINATE; - }); - - // Store the in-flight promise so concurrent callers share it - psCache = { output: "", timestamp: now, promise }; - - return promise; -} - -/** - * Check if a process named "claude" is running in the given runtime handle's context. - * Uses ps to find processes by TTY (for tmux) or by PID. - */ -async function findClaudeProcess( - handle: RuntimeHandle, -): Promise { - try { - // For tmux runtime, get the pane TTY and find claude on it - if (handle.runtimeName === "tmux" && handle.id) { - if (isWindows()) return null; - const { stdout: ttyOut } = await execFileAsync( - "tmux", - ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], - { timeout: 30_000 }, - ); - // Iterate all pane TTYs (multi-pane sessions) — succeed on any match - const ttys = ttyOut - .trim() - .split("\n") - .map((t) => t.trim()) - .filter(Boolean); - if (ttys.length === 0) return null; - - const psOut = await getCachedProcessList(); - if (psOut === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; - - const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); - // Match "claude" as a word boundary — prevents false positives on - // names like "claude-code" or paths that merely contain the substring. - const processRe = /(?:^|\/)claude(?:\s|$)/; - for (const line of psOut.split("\n")) { - const cols = line.trimStart().split(/\s+/); - if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; - const args = cols.slice(2).join(" "); - if (processRe.test(args)) { - return parseInt(cols[0] ?? "0", 10); - } - } - return null; - } - - // For process runtime, check if the PID stored in handle data is alive - const rawPid = handle.data["pid"]; - const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); - if (Number.isFinite(pid) && pid > 0) { - try { - process.kill(pid, 0); // Signal 0 = check existence - return pid; - } catch (err: unknown) { - // EPERM means the process exists but we lack permission to signal it - if (err instanceof Error && "code" in err && err.code === "EPERM") { - return pid; - } - return null; - } - } - - // No reliable way to identify the correct process for this session - return null; - } catch { - return PROCESS_PROBE_INDETERMINATE; - } -} - -// ============================================================================= -// Terminal Output Patterns for detectActivity -// ============================================================================= - -/** Classify Claude Code's activity state from terminal output (pure, sync). */ -function classifyTerminalOutput(terminalOutput: string): ActivityState { - // Empty output — can't determine state - if (!terminalOutput.trim()) return "idle"; - - const lines = terminalOutput.trim().split("\n"); - const lastLine = lines[lines.length - 1]?.trim() ?? ""; - - // Check the last line FIRST — if the prompt is visible, the agent is idle - // regardless of historical output (e.g. "Reading file..." from earlier). - // The ❯ is Claude Code's prompt character. - if (/^[❯>$#]\s*$/.test(lastLine)) return "idle"; - - // Check the bottom of the buffer for permission prompts BEFORE checking - // full-buffer active indicators. Historical "Thinking"/"Reading" text in - // the buffer must not override a current permission prompt at the bottom. - const tail = lines.slice(-5).join("\n"); - if (/Do you want to proceed\?/i.test(tail)) return "waiting_input"; - if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input"; - if (/bypass.*permissions/i.test(tail)) return "waiting_input"; - - // Everything else is "active" — the agent is processing, waiting for - // output, or showing content. Specific patterns (e.g. "esc to interrupt", - // "Thinking", "Reading") all map to "active" so no need to check them - // individually. - return "active"; -} - // ============================================================================= // Hook Setup Helper // ============================================================================= @@ -947,87 +744,31 @@ function createClaudeCodeAgent(): Agent { return classifyTerminalOutput(terminalOutput); }, + async recordActivity(session: Session, terminalOutput: string): Promise { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => + this.detectActivity(output), + ); + }, + async isProcessRunning(handle: RuntimeHandle): Promise { - const pid = await findClaudeProcess(handle); - if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; - return pid !== null; + return isClaudeProcessAlive(handle); }, async getActivityState( session: Session, readyThresholdMs?: number, ): Promise { - const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; - - // Check if process is running first - const exitedAt = new Date(); - if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; - const running = await this.isProcessRunning(session.runtimeHandle); - if (running === PROCESS_PROBE_INDETERMINATE) return null; - if (!running) return { state: "exited", timestamp: exitedAt }; - - // Process is running - check JSONL session file for activity - if (!session.workspacePath) { - // No workspace path — cannot determine activity without it - return null; - } - - const projectPath = toClaudeProjectPath(session.workspacePath); - const projectDir = join(homedir(), ".claude", "projects", projectPath); - - const sessionFile = await findLatestSessionFile(projectDir); - if (!sessionFile) { - // No session file yet — process is running but no conversation started. - // Treat as idle (waiting for first task). - return { state: "idle", timestamp: session.createdAt }; - } - - const entry = await readLastJsonlEntry(sessionFile); - if (!entry) { - // Empty file or read error — cannot determine activity - return null; - } - - // If the JSONL entry predates this session, it's from a previous session - // in the same worktree. Treat as no data (agent hasn't written yet). - if (session.createdAt && entry.modifiedAt < session.createdAt) { - return { state: "idle", timestamp: session.createdAt }; - } - - const ageMs = Date.now() - entry.modifiedAt.getTime(); - const timestamp = entry.modifiedAt; - - const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); - switch (entry.lastType) { - case "user": - case "tool_use": - case "progress": - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - - case "assistant": - case "system": - case "summary": - case "result": - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - - case "permission_request": - return { state: "waiting_input", timestamp }; - - case "error": - return { state: "blocked", timestamp }; - - default: - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - } + return getClaudeActivityState(session, readyThresholdMs, (handle) => + this.isProcessRunning(handle), + ); }, async getSessionInfo(session: Session): Promise { if (!session.workspacePath) return null; // Build the Claude project directory path - const projectPath = toClaudeProjectPath(session.workspacePath); + const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath)); const projectDir = join(homedir(), ".claude", "projects", projectPath); // Find the latest session JSONL file @@ -1057,7 +798,7 @@ function createClaudeCodeAgent(): Agent { if (!session.workspacePath) return null; // Find Claude's project directory for this workspace - const projectPath = toClaudeProjectPath(session.workspacePath); + const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath)); const projectDir = join(homedir(), ".claude", "projects", projectPath); // Find the latest session JSONL file diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index ef78c917f..81a08f554 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -34,15 +34,9 @@ "clean": "rm -rf dist" }, "dependencies": { - "@aoagents/ao-core": "workspace:*" - }, - "peerDependencies": { - "composio-core": ">=0.5.0" - }, - "peerDependenciesMeta": { - "composio-core": { - "optional": true - } + "@aoagents/ao-core": "workspace:*", + "@composio/core": "^0.9.0", + "zod": "^3.25.76" }, "devDependencies": { "@types/node": "^25.2.3", diff --git a/packages/plugins/notifier-composio/src/index.test.ts b/packages/plugins/notifier-composio/src/index.test.ts index 99dbcb2ae..ab0168aa7 100644 --- a/packages/plugins/notifier-composio/src/index.test.ts +++ b/packages/plugins/notifier-composio/src/index.test.ts @@ -2,6 +2,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { OrchestratorEvent, NotifyAction } from "@aoagents/ao-core"; import { manifest, create } from "./index.js"; +const { mockToolsExecute, mockConstructorOptions } = vi.hoisted(() => ({ + mockToolsExecute: vi.fn().mockResolvedValue({ successful: true }), + mockConstructorOptions: [] as Array>, +})); + +vi.mock("@composio/core", () => { + function MockComposio(opts: Record) { + mockConstructorOptions.push(opts); + return { tools: { execute: mockToolsExecute } }; + } + return { Composio: MockComposio }; +}); + function makeEvent(overrides: Partial = {}): OrchestratorEvent { return { id: "evt-1", @@ -16,29 +29,44 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } -const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true }); +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} -vi.mock("composio-core", () => { - // Must use a regular function (not arrow) to be callable with `new` - function MockComposio() { - return { executeAction: mockExecuteAction }; - } - return { Composio: MockComposio }; -}); +function getSlackAttachment(): any { + const callArgs = mockToolsExecute.mock.calls[0][1]; + return JSON.parse(String(callArgs.arguments.attachments))[0]; +} + +function getSlackActions(): any[] { + const attachment = getSlackAttachment(); + return attachment.blocks.find((block: any) => block.type === "actions")?.elements ?? []; +} describe("notifier-composio", () => { - const originalEnv = process.env.COMPOSIO_API_KEY; + const originalEnv = { + COMPOSIO_API_KEY: process.env.COMPOSIO_API_KEY, + COMPOSIO_USER_ID: process.env.COMPOSIO_USER_ID, + COMPOSIO_ENTITY_ID: process.env.COMPOSIO_ENTITY_ID, + }; beforeEach(() => { vi.clearAllMocks(); + mockConstructorOptions.length = 0; + mockToolsExecute.mockResolvedValue({ successful: true }); delete process.env.COMPOSIO_API_KEY; + delete process.env.COMPOSIO_USER_ID; + delete process.env.COMPOSIO_ENTITY_ID; }); afterEach(() => { - if (originalEnv !== undefined) { - process.env.COMPOSIO_API_KEY = originalEnv; - } else { - delete process.env.COMPOSIO_API_KEY; + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) process.env[key] = value; + else Reflect.deleteProperty(process.env, key); } }); @@ -54,15 +82,24 @@ describe("notifier-composio", () => { }); describe("create — config parsing", () => { - it("reads apiKey from config", () => { + it("reads apiKey from config", async () => { const notifier = create({ composioApiKey: "test-key" }); - expect(notifier.name).toBe("composio"); + await notifier.notify(makeEvent()); + expect(mockConstructorOptions[0]).toEqual({ apiKey: "test-key" }); }); - it("reads apiKey from COMPOSIO_API_KEY env var", () => { + it("reads apiKey from COMPOSIO_API_KEY env var", async () => { process.env.COMPOSIO_API_KEY = "env-key"; const notifier = create(); - expect(notifier.name).toBe("composio"); + await notifier.notify(makeEvent()); + expect(mockConstructorOptions[0]).toEqual({ apiKey: "env-key" }); + }); + + it("resolves env placeholders in composioApiKey config", async () => { + process.env.COMPOSIO_API_KEY = "placeholder-key"; + const notifier = create({ composioApiKey: "${COMPOSIO_API_KEY}" }); + await notifier.notify(makeEvent()); + expect(mockConstructorOptions[0]).toEqual({ apiKey: "placeholder-key" }); }); it("throws on invalid defaultApp", () => { @@ -79,6 +116,12 @@ describe("notifier-composio", () => { expect(() => create({ composioApiKey: "k", defaultApp: "discord" })).not.toThrow(); }); + it("throws on invalid Discord mode", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "discord", mode: "voice" })).toThrow( + 'Invalid Discord mode: "voice"', + ); + }); + it("accepts gmail as defaultApp with emailTo", () => { expect(() => create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" }), @@ -95,9 +138,11 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k" }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", expect.objectContaining({ - action: "SLACK_SEND_MESSAGE", + userId: "aoagent", + arguments: expect.objectContaining({ markdown_text: expect.any(String) }), }), ); }); @@ -108,77 +153,383 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k", defaultApp: "slack" }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.any(Object)); + }); + + it("calls DISCORDBOT_CREATE_MESSAGE for discord bot mode", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({ - action: "SLACK_SEND_MESSAGE", + arguments: expect.objectContaining({ + channel_id: "1234567890", + content: expect.stringContaining("Session Spawned"), + embeds: [ + expect.objectContaining({ + title: expect.stringContaining("Session Spawned"), + fields: expect.arrayContaining([ + expect.objectContaining({ name: "Project", value: "my-project" }), + expect.objectContaining({ name: "Session", value: "app-1" }), + ]), + }), + ], + allowed_mentions: { parse: [] }, + }), }), ); }); - it("calls DISCORD_SEND_MESSAGE for discord app", async () => { - const notifier = create({ composioApiKey: "k", defaultApp: "discord" }); + it("calls DISCORDBOT_EXECUTE_WEBHOOK for discord webhook mode", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + connectedAccountId: "ca_discord_webhook", + }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_EXECUTE_WEBHOOK", expect.objectContaining({ - action: "DISCORD_SEND_MESSAGE", + arguments: expect.objectContaining({ + webhook_id: "1234567890", + webhook_token: "webhook-token", + content: expect.stringContaining("Session Spawned"), + embeds: [ + expect.objectContaining({ + title: expect.stringContaining("Session Spawned"), + }), + ], + allowed_mentions: { parse: [] }, + }), + connectedAccountId: "ca_discord_webhook", }), ); }); + it("uses webhook mode when Discord webhookUrl is configured without mode", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + connectedAccountId: "ca_discord_webhook", + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_EXECUTE_WEBHOOK", + expect.any(Object), + ); + }); + + it("fails fast on invalid Discord webhook URLs", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/not-a-webhook", + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("Invalid Discord webhookUrl"); + }); + it("calls GMAIL_SEND_EMAIL for gmail app", async () => { const notifier = create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "test@test.com", + connectedAccountId: "ca_gmail", }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", expect.objectContaining({ - action: "GMAIL_SEND_EMAIL", + connectedAccountId: "ca_gmail", + arguments: expect.objectContaining({ + recipient_email: "test@test.com", + subject: "[AO] Session Spawned: app-1", + is_html: true, + }), }), ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.body).toContain(""); + expect(callArgs.arguments.body).toContain("Session Spawned"); + expect(callArgs.arguments.body).toContain("Session app-1 spawned successfully"); + }); + + it("formats Gmail CI notifications as a professional HTML email brief", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + await notifier.notify( + makeEvent({ + type: "ci.failing", + priority: "action", + sessionId: "demo-agent-19", + projectId: "demo", + message: "CI is failing on PR #1579", + data: makeV3Data({ + subject: { + session: { id: "demo-agent-19", projectId: "demo" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + branch: "ao/demo-notifier-harness", + baseBranch: "main", + }, + issue: { + id: "AO-1579", + title: "Make AO notification payloads API-grade", + }, + }, + ci: { + status: "failing", + failedChecks: [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks", + }, + ], + }, + }), + }), + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.subject).toBe("[AO] CI failing on PR #1579"); + expect(callArgs.arguments.is_html).toBe(true); + expect(callArgs.arguments.body).toContain(""); + expect(callArgs.arguments.body).toContain("CI is failing on PR #1579"); + expect(callArgs.arguments.body).toContain("Normalize AO notifier payloads"); + expect(callArgs.arguments.body).toContain("Action required"); + expect(callArgs.arguments.body).toContain("Pull Request"); + expect(callArgs.arguments.body).toContain("#1579 - Normalize AO notifier payloads"); + expect(callArgs.arguments.body).toContain("typecheck: failed/FAILURE"); + expect(callArgs.arguments.body).not.toContain("👉"); }); it("routes to channelId when set", async () => { const notifier = create({ composioApiKey: "k", channelId: "C123" }); await notifier.notify(makeEvent()); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.channel).toBe("C123"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.channel).toBe("C123"); }); - it("routes to channelName when channelId not set", async () => { + it("routes to normalized channelName when channelId not set", async () => { const notifier = create({ composioApiKey: "k", channelName: "#general" }); await notifier.notify(makeEvent()); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.channel).toBe("#general"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.channel).toBe("general"); }); - it("includes priority emoji in text", async () => { + it("formats Slack notifications as rich attachments", async () => { const notifier = create({ composioApiKey: "k" }); await notifier.notify(makeEvent({ priority: "urgent" })); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("\u{1F6A8}"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.markdown_text).toContain("Urgent"); + expect(callArgs.arguments.text).toContain("Urgent"); + expect(callArgs.arguments.unfurl_links).toBe(false); + expect(callArgs.arguments.unfurl_media).toBe(false); + + const attachment = getSlackAttachment(); + expect(attachment.color).toBe("#E01E5A"); + expect(attachment.fallback).toContain("Urgent"); + expect(attachment.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "header", + text: expect.objectContaining({ + text: expect.stringContaining(":rotating_light:"), + }), + }), + expect.objectContaining({ + type: "section", + fields: expect.arrayContaining([ + expect.objectContaining({ text: expect.stringContaining("*Project*") }), + expect.objectContaining({ text: expect.stringContaining("my-project") }), + ]), + }), + ]), + ); }); - it("includes prUrl when present as string", async () => { + it("escapes user-controlled Slack mrkdwn characters", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify( + makeEvent({ message: "Fix *bold* _italic_ ~strike~ `code` & > done" }), + ); + + const section = getSlackAttachment().blocks.find((block: any) => block.type === "section"); + expect(section.text.text).toBe( + "Fix *bold* _italic_ ~strike~ `code` & <tag> > done", + ); + }); + + it("escapes right parentheses in Discord markdown link URLs", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { + number: 1, + title: "Parser test", + url: "https://github.com/org/repo/pull/1?a=(test)", + }, + }, + }), + }), + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + const pullRequestField = callArgs.arguments.embeds[0].fields.find( + (field: any) => field.name === "Pull Request", + ); + expect(pullRequestField.value).toContain( + "[#1](https://github.com/org/repo/pull/1?a=(test%29)", + ); + }); + + it("includes subject.pr.url when present in v3 data", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { number: 1, url: "https://github.com/pull/1" }, + }, + }), + }), + ); + + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "View PR" }), + url: "https://github.com/pull/1", + style: "primary", + }), + ]), + ); + }); + + it("ignores legacy flat prUrl", async () => { const notifier = create({ composioApiKey: "k" }); await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/pull/1" } })); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("https://github.com/pull/1"); + expect(getSlackActions()).toEqual([]); + expect(getSlackAttachment().fallback).not.toContain("https://github.com/pull/1"); }); - it("ignores prUrl when not a string", async () => { - const notifier = create({ composioApiKey: "k" }); - await notifier.notify(makeEvent({ data: { prUrl: 42 } })); + it("passes userId, connectedAccountId, and default Slack tool version", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "slack", + userId: "user_123", + connectedAccountId: "ca_123", + }); + await notifier.notify(makeEvent()); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).not.toContain("PR:"); + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ + userId: "user_123", + connectedAccountId: "ca_123", + version: "20260508_00", + }), + ); + }); + + it("keeps entityId as a backward-compatible userId alias", async () => { + const notifier = create({ composioApiKey: "k", entityId: "legacy-user" }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ userId: "legacy-user" }), + ); + }); + + it("reads userId from COMPOSIO_USER_ID env var", async () => { + process.env.COMPOSIO_USER_ID = "env-user"; + const notifier = create({ composioApiKey: "k" }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ userId: "env-user" }), + ); + }); + + it("supports configured toolVersion overrides", async () => { + const notifier = create({ composioApiKey: "k", toolVersion: "20260101_00" }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ version: "20260101_00" }), + ); + }); + + it("supports app-specific toolVersions overrides", async () => { + const notifier = create({ + composioApiKey: "k", + toolVersion: "ignored", + toolVersions: { slack: "20260202_00" }, + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ version: "20260202_00" }), + ); + }); + + it("passes the default Gmail tool version", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", + expect.objectContaining({ version: "20260506_01" }), + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("No toolVersion configured"), + ); + warnSpy.mockRestore(); }); }); @@ -191,9 +542,21 @@ describe("notifier-composio", () => { ]; await notifier.notifyWithActions!(makeEvent(), actions); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("Merge"); - expect(callArgs.params.text).toContain("Kill"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "Merge" }), + url: "https://github.com/merge", + style: "primary", + }), + expect.objectContaining({ + text: expect.objectContaining({ text: "Kill" }), + action_id: "ao_kill_1", + value: "/api/kill", + style: "danger", + }), + ]), + ); }); it("includes URL actions as links", async () => { @@ -201,8 +564,14 @@ describe("notifier-composio", () => { const actions: NotifyAction[] = [{ label: "View PR", url: "https://github.com/pull/42" }]; await notifier.notifyWithActions!(makeEvent(), actions); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("https://github.com/pull/42"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "View PR" }), + url: "https://github.com/pull/42", + }), + ]), + ); }); it("renders callback-only actions without URL", async () => { @@ -210,20 +579,106 @@ describe("notifier-composio", () => { const actions: NotifyAction[] = [{ label: "Restart", callbackEndpoint: "/api/restart" }]; await notifier.notifyWithActions!(makeEvent(), actions); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("- Restart"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "Restart" }), + action_id: "ao_restart_0", + value: "/api/restart", + }), + ]), + ); }); it("uses correct tool slug for configured app", async () => { - const notifier = create({ composioApiKey: "k", defaultApp: "discord" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + }); const actions: NotifyAction[] = [{ label: "Test", url: "https://example.com" }]; await notifier.notifyWithActions!(makeEvent(), actions); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({ - action: "DISCORD_SEND_MESSAGE", + arguments: expect.objectContaining({ + embeds: [ + expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "Actions", + value: expect.stringContaining("[Test](https://example.com)"), + }), + ]), + }), + ], + components: [ + { + type: 1, + components: [{ type: 2, style: 5, label: "Test", url: "https://example.com" }], + }, + ], + }), }), ); + warnSpy.mockRestore(); + }); + + it("formats Gmail actions with a professional subject and action links", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + const actions: NotifyAction[] = [ + { label: "Open dashboard", url: "http://localhost:3000" }, + { label: "Acknowledge", callbackEndpoint: "http://localhost:3000/api/ack" }, + ]; + await notifier.notifyWithActions!( + makeEvent({ + type: "merge.ready", + priority: "action", + sessionId: "demo-agent-29", + projectId: "demo", + message: "PR #1579 is ready to merge", + data: makeV3Data({ + subject: { + session: { id: "demo-agent-29", projectId: "demo" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + }, + }, + transition: { kind: "session_status", from: "approved", to: "mergeable" }, + ci: { status: "passing" }, + review: { decision: "approved" }, + merge: { ready: true, conflicts: false, isBehind: false }, + }), + }), + actions, + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.subject).toBe("[AO] PR #1579 ready to merge"); + expect(callArgs.arguments.is_html).toBe(true); + expect(callArgs.arguments.body).toContain(""); + expect(callArgs.arguments.body).toContain("Ready to merge"); + expect(callArgs.arguments.body).toContain("PR #1579 is ready to merge"); + expect(callArgs.arguments.body).toContain("Normalize AO notifier payloads"); + expect(callArgs.arguments.body).toContain("View pull request"); + expect(callArgs.arguments.body).toContain("Transition"); + expect(callArgs.arguments.body).toContain("approved -> mergeable"); + expect(callArgs.arguments.body).toContain("Passing"); + expect(callArgs.arguments.body).toContain("Approved"); + expect(callArgs.arguments.body).toContain("Ready"); + expect(callArgs.arguments.body).toContain("Actions"); + expect(callArgs.arguments.body).toContain('href="http://localhost:3000"'); + expect(callArgs.arguments.body).toContain('href="http://localhost:3000/api/ack"'); }); }); @@ -232,16 +687,42 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k" }); await notifier.post!("Hello from AO"); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toBe("Hello from AO"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.markdown_text).toBe("Hello from AO"); }); it("overrides channel from context", async () => { const notifier = create({ composioApiKey: "k", channelName: "#default" }); await notifier.post!("test", { channel: "#override" }); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.channel).toBe("#override"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.channel).toBe("override"); + }); + + it("uses Gmail recipient_email for HTML post messages", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + await notifier.post!("Hello from AO"); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", + expect.objectContaining({ + connectedAccountId: "ca_gmail", + arguments: { + recipient_email: "test@test.com", + subject: "Agent Orchestrator Message", + body: expect.stringContaining(""), + is_html: true, + }, + }), + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.body).toContain("Hello from AO"); }); it("returns null", async () => { @@ -253,7 +734,7 @@ describe("notifier-composio", () => { describe("error handling", () => { it("throws when SDK returns unsuccessful result", async () => { - mockExecuteAction.mockResolvedValueOnce({ + mockToolsExecute.mockResolvedValueOnce({ successful: false, error: "channel not found", }); @@ -263,7 +744,7 @@ describe("notifier-composio", () => { }); it("wraps SDK error with descriptive message", async () => { - mockExecuteAction.mockResolvedValueOnce({ + mockToolsExecute.mockResolvedValueOnce({ successful: false, error: undefined, }); @@ -271,6 +752,46 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k" }); await expect(notifier.notify(makeEvent())).rejects.toThrow("unknown error"); }); + + it("adds setup guidance when no connected account is found", async () => { + mockToolsExecute.mockRejectedValueOnce( + new Error("No connected account found for user default for toolkit slack"), + ); + + const notifier = create({ composioApiKey: "k" }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("ao setup composio"); + }); + + it("uses mail setup guidance for Gmail connection errors", async () => { + mockToolsExecute.mockRejectedValueOnce( + new Error("No connected account found for user aoagent for toolkit gmail"), + ); + + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("ao setup composio-mail"); + }); + + it("requires connectedAccountId before executing Gmail notifications", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("connectedAccountId is required"); + expect(mockToolsExecute).not.toHaveBeenCalled(); + }); + + it("rejects invalid test client overrides", () => { + expect(() => create({ composioApiKey: "k", _clientOverride: {} })).toThrow("tools.execute"); + }); }); describe("no-op when no apiKey", () => { @@ -278,9 +799,33 @@ describe("notifier-composio", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const notifier = create(); await notifier.notify(makeEvent()); - expect(mockExecuteAction).not.toHaveBeenCalled(); + expect(mockToolsExecute).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No composioApiKey")); warnSpy.mockRestore(); }); }); + + describe("client override", () => { + it("supports direct @composio/core tools.execute clients", async () => { + const execute = vi.fn().mockResolvedValue({ successful: true }); + const notifier = create({ + composioApiKey: "k", + userId: "user_123", + connectedAccountId: "ca_123", + _clientOverride: { tools: { execute } }, + }); + + await notifier.notify(makeEvent()); + + expect(execute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ + userId: "user_123", + connectedAccountId: "ca_123", + arguments: expect.objectContaining({ markdown_text: expect.any(String) }), + }), + ); + expect(mockToolsExecute).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/plugins/notifier-composio/src/index.ts b/packages/plugins/notifier-composio/src/index.ts index 08bb21c53..6e9372ffb 100644 --- a/packages/plugins/notifier-composio/src/index.ts +++ b/packages/plugins/notifier-composio/src/index.ts @@ -1,12 +1,24 @@ -import type { - PluginModule, - Notifier, - OrchestratorEvent, - NotifyAction, - NotifyContext, - EventPriority, +import { + getNotificationDataV3, + recordActivityEvent, + type EventPriority, + type Notifier, + type NotifyAction, + type NotifyContext, + type NotificationCICheck, + type NotificationDataV3, + type OrchestratorEvent, + type PluginModule, } from "@aoagents/ao-core"; +// Module-level guard so we only emit notifier.dep_missing once per process. +let depMissingEmitted = false; + +/** Test-only: reset the once-per-process dep_missing guard. */ +export function _resetDepMissingEmittedForTesting(): void { + depMissingEmitted = false; +} + export const manifest = { name: "composio", slot: "notifier" as const, @@ -21,47 +33,166 @@ const PRIORITY_EMOJI: Record = { info: "\u{2139}\u{FE0F}", }; +function getSubjectPRUrl(event: OrchestratorEvent): string | undefined { + return getNotificationDataV3(event.data)?.subject.pr?.url; +} + +function getCIStatus(event: OrchestratorEvent): string | undefined { + return getNotificationDataV3(event.data)?.ci?.status; +} + +function getFailedCheckNames(event: OrchestratorEvent): string[] { + return getNotificationDataV3(event.data)?.ci?.failedChecks?.map((check) => check.name) ?? []; +} + type ComposioApp = "slack" | "discord" | "gmail"; +type DiscordMode = "webhook" | "bot"; const APP_TOOL_SLUG: Record = { slack: "SLACK_SEND_MESSAGE", - discord: "DISCORD_SEND_MESSAGE", + discord: "DISCORDBOT_CREATE_MESSAGE", gmail: "GMAIL_SEND_EMAIL", }; +const DEFAULT_TOOL_VERSION: Partial> = { + slack: "20260508_00", + discord: "20260429_01", + gmail: "20260506_01", +}; + const VALID_APPS = new Set(["slack", "discord", "gmail"]); +const VALID_DISCORD_MODES = new Set(["webhook", "bot"]); +const DEFAULT_COMPOSIO_USER_ID = "aoagent"; const GMAIL_SUBJECT = "Agent Orchestrator Notification"; +const GMAIL_POST_SUBJECT = "Agent Orchestrator Message"; +const DISCORD_WEBHOOK_TOOL_SLUG = "DISCORDBOT_EXECUTE_WEBHOOK"; +const DISCORD_EMBED_TITLE_MAX = 256; +const DISCORD_EMBED_DESCRIPTION_MAX = 4096; +const DISCORD_FIELD_NAME_MAX = 256; +const DISCORD_FIELD_VALUE_MAX = 1024; +const DISCORD_MAX_FIELDS = 25; -interface ComposioToolkit { - executeAction(params: { - action: string; - params: Record; - entityId?: string; - }): Promise<{ successful: boolean; data?: unknown; error?: string }>; +interface ComposioExecuteParams { + userId: string; + connectedAccountId?: string; + version?: string; + dangerouslySkipVersionCheck?: boolean; + arguments: Record; +} + +interface ComposioExecuteResult { + successful?: boolean; + data?: unknown; + error?: unknown; +} + +interface ComposioToolsClient { + tools: { + execute(action: string, params: ComposioExecuteParams): Promise; + }; +} + +interface DiscordTone { + emoji: string; + label: string; + color: number; +} + +interface DiscordEmbed { + title: string; + description: string; + color: number; + url?: string; + fields?: { name: string; value: string; inline?: boolean }[]; + timestamp?: string; + footer?: { text: string }; +} + +interface DiscordComponentButton { + type: 2; + style: 5; + label: string; + url: string; +} + +interface DiscordActionRow { + type: 1; + components: DiscordComponentButton[]; +} + +interface DiscordMessagePayload { + content?: string; + embeds?: DiscordEmbed[]; + components?: DiscordActionRow[]; + allowed_mentions?: { parse: string[] }; +} + +interface SlackTone { + emoji: string; + label: string; + color: string; +} + +interface SlackButton { + type: "button"; + text: { + type: "plain_text"; + text: string; + emoji: true; + }; + url?: string; + action_id?: string; + value?: string; + style?: "primary" | "danger"; +} + +interface SlackAttachment { + color: string; + fallback: string; + blocks: unknown[]; +} + +interface SlackMessagePayload { + markdown_text: string; + text: string; + attachments: string; + unfurl_links: boolean; + unfurl_media: boolean; +} + +function isComposioToolsClient(value: unknown): value is ComposioToolsClient { + return ( + value !== null && + typeof value === "object" && + "tools" in value && + typeof (value as { tools?: { execute?: unknown } }).tools?.execute === "function" + ); } /** - * Lazy-load composio-core SDK. - * Returns null if the package is not installed. + * Lazy-load the bundled @composio/core SDK. * - * We use dynamic import + unknown casting because composio-core is an - * optional peer dependency — it may or may not be installed, and its - * TypeScript types may not match our internal interface exactly. + * Dynamic import keeps the plugin lightweight at module-load time and lets + * tests inject a mock client at the I/O boundary. */ -async function loadComposioSDK(apiKey: string): Promise { +async function loadComposioSDK(apiKey: string): Promise { try { - // String literal import so vitest can intercept it for mocking. - // The `as unknown as …` cast is safe because we validate the shape below. - const mod = (await import("composio-core")) as unknown as Record; + const mod = (await import("@composio/core")) as unknown as Record; const ComposioClass = (mod.Composio ?? (mod.default as Record | undefined)?.Composio ?? mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined; + if (typeof ComposioClass !== "function") { - throw new Error("Could not find Composio class in composio-core module"); + throw new Error("Could not find Composio class in @composio/core module"); } + const client = new ComposioClass({ apiKey }); - return client as ComposioToolkit; + if (!isComposioToolsClient(client)) { + throw new Error("Composio SDK client does not expose tools.execute()"); + } + + return client; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; @@ -71,21 +202,108 @@ async function loadComposioSDK(apiKey: string): Promise message.includes("MODULE_NOT_FOUND") || code === "ERR_MODULE_NOT_FOUND" ) { + // User-actionable. Emit once per process so RCA can answer + // "why is the composio notifier silent?" without spamming on every notify call. + if (!depMissingEmitted) { + depMissingEmitted = true; + recordActivityEvent({ + source: "notifier", + kind: "notifier.dep_missing", + level: "error", + summary: "Composio SDK (@composio/core) is not installed", + data: { + plugin: "notifier-composio", + package: "@composio/core", + installHint: "pnpm add @composio/core", + }, + }); + } return null; } throw err; } } +function stringConfig( + config: Record | undefined, + key: string, +): string | undefined { + const value = config?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function resolveEnvReference(value: string | undefined): string | undefined { + if (!value) return undefined; + const match = value.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/); + if (!match) return value; + return process.env[match[1] ?? match[2] ?? ""]; +} + +function boolConfig(config: Record | undefined, key: string): boolean { + return config?.[key] === true; +} + +function parseDiscordWebhookUrl(webhookUrl: string): { webhookId: string; webhookToken: string } { + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new Error("[notifier-composio] Invalid Discord webhookUrl."); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + const webhookIndex = segments.findIndex((segment) => segment === "webhooks"); + const webhookId = webhookIndex >= 0 ? segments[webhookIndex + 1] : undefined; + const webhookToken = webhookIndex >= 0 ? segments[webhookIndex + 2] : undefined; + + if (!webhookId || !webhookToken) { + throw new Error( + "[notifier-composio] Invalid Discord webhookUrl. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN", + ); + } + + return { + webhookId: decodeURIComponent(webhookId), + webhookToken: decodeURIComponent(webhookToken), + }; +} + +function resolveDiscordMode( + config: Record | undefined, + defaultApp: ComposioApp, + webhookUrl: string | undefined, +): DiscordMode | undefined { + if (defaultApp !== "discord") return undefined; + + const mode = stringConfig(config, "mode"); + if (mode) { + if (!VALID_DISCORD_MODES.has(mode)) { + throw new Error( + `[notifier-composio] Invalid Discord mode: "${mode}". Must be one of: webhook, bot`, + ); + } + return mode as DiscordMode; + } + + return webhookUrl ? "webhook" : "bot"; +} + function formatNotifyText(event: OrchestratorEvent): string { const emoji = PRIORITY_EMOJI[event.priority]; const parts = [`${emoji} *${event.type}* — ${event.sessionId}`, event.message]; - const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; + const prUrl = getSubjectPRUrl(event); if (prUrl) { parts.push(`PR: ${prUrl}`); } + const ciStatus = getCIStatus(event); + if (ciStatus) { + const failedChecks = getFailedCheckNames(event); + const failedCheckText = failedChecks.length > 0 ? ` (failed: ${failedChecks.join(", ")})` : ""; + parts.push(`CI: ${ciStatus}${failedCheckText}`); + } + return parts.join("\n"); } @@ -99,56 +317,1252 @@ function formatActionsText(event: OrchestratorEvent, actions: NotifyAction[]): s return `${base}\n\nActions:\n${actionLines.join("\n")}`; } +const DISCORD_SUCCESS_TONE: DiscordTone = { + emoji: "\u{2705}", + label: "Complete", + color: 0x57f287, +}; + +const DISCORD_PRIORITY_TONE: Record = { + urgent: { + emoji: "\u{1F6A8}", + label: "Urgent", + color: 0xed4245, + }, + action: { + emoji: "\u{1F449}", + label: "Action required", + color: 0x5865f2, + }, + warning: { + emoji: "\u{26A0}\u{FE0F}", + label: "Warning", + color: 0xfee75c, + }, + info: { + emoji: "\u{2139}\u{FE0F}", + label: "Information", + color: 0x3498db, + }, +}; + +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function priorityLabel(priority: EventPriority): string { + switch (priority) { + case "urgent": + return "Urgent"; + case "action": + return "Action required"; + case "warning": + return "Warning"; + case "info": + return "Information"; + } +} + +function truncate(value: string, maxLength = 90): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; +} + +function truncateUnicode(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value; +} + +function discordToneForEvent(event: OrchestratorEvent): DiscordTone { + if (event.type === "merge.ready") return { ...DISCORD_SUCCESS_TONE, label: "Ready to merge" }; + if (event.type === "summary.all_complete") { + return { ...DISCORD_SUCCESS_TONE, label: "All complete" }; + } + if (event.type === "ci.failing" || event.type === "session.stuck") { + return DISCORD_PRIORITY_TONE.urgent; + } + if (event.type === "review.changes_requested") return DISCORD_PRIORITY_TONE.warning; + return DISCORD_PRIORITY_TONE[event.priority] ?? DISCORD_PRIORITY_TONE.info; +} + +function formatDiscordTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function formatDiscordValue(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null || value === "") return "Not available"; + return truncateUnicode(String(value), DISCORD_FIELD_VALUE_MAX); +} + +function appendDiscordField( + fields: NonNullable, + name: string, + value: string | number | boolean | undefined | null, + inline = true, +): void { + if (value === undefined || value === null || value === "") return; + if (fields.length >= DISCORD_MAX_FIELDS) return; + fields.push({ + name: truncateUnicode(name, DISCORD_FIELD_NAME_MAX), + value: formatDiscordValue(value), + inline, + }); +} + +function formatDiscordMarkdownLink(label: string, url: string): string { + const safeLabel = label.replaceAll("[", "").replaceAll("]", "").replace(/[()]/g, ""); + const safeUrl = url.replace(/\)/g, "%29"); + return `[${safeLabel}](${safeUrl})`; +} + +function formatDiscordBranch(data: NotificationDataV3 | null): string | undefined { + const pr = data?.subject.pr; + if (pr?.branch && pr.baseBranch) return `${pr.branch} -> ${pr.baseBranch}`; + return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch; +} + +function formatDiscordCheck(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const label = `${check.name}: ${status}`; + return check.url ? formatDiscordMarkdownLink(label, check.url) : label; +} + +function isAbsoluteHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function formatDiscordCiStatus(data: NotificationDataV3): string { + if (!data.ci?.status) return ""; + const ciEmoji = data.ci.status === "passing" ? "\u{2705}" : "\u{274C}"; + const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? []; + const failedCheckText = failedChecks.length > 0 ? `\nFailed: ${failedChecks.join(", ")}` : ""; + return `${ciEmoji} ${titleCaseStatus(data.ci.status)}${failedCheckText}`; +} + +function appendDiscordDataFields( + fields: NonNullable, + data: NotificationDataV3 | null, +): void { + if (!data) return; + + const pr = data.subject.pr; + const issue = data.subject.issue; + const branch = formatDiscordBranch(data); + + appendDiscordField( + fields, + "Pull Request", + pr + ? `${formatDiscordMarkdownLink(`#${pr.number}`, pr.url)}${pr.title ? ` - ${pr.title}` : ""}` + : undefined, + false, + ); + appendDiscordField(fields, "Branch", branch); + appendDiscordField( + fields, + "Issue", + issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined, + ); + appendDiscordField(fields, "CI", data.ci?.status ? formatDiscordCiStatus(data) : undefined); + appendDiscordField( + fields, + "Review", + data.review?.decision ? titleCaseStatus(data.review.decision) : undefined, + ); + appendDiscordField(fields, "Review Threads", data.review?.unresolvedThreads); + appendDiscordField( + fields, + "Merge", + typeof data.merge?.ready === "boolean" ? (data.merge.ready ? "Ready" : "Not ready") : undefined, + ); + appendDiscordField( + fields, + "Conflicts", + typeof data.merge?.conflicts === "boolean" + ? data.merge.conflicts + ? "Found" + : "None" + : undefined, + ); + appendDiscordField( + fields, + "Sync", + typeof data.merge?.isBehind === "boolean" + ? data.merge.isBehind + ? "Behind base" + : "Up to date" + : undefined, + ); + appendDiscordField( + fields, + "Transition", + data.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined, + ); + appendDiscordField( + fields, + "Reaction", + data.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined, + ); + appendDiscordField( + fields, + "Escalation", + data.escalation ? `${data.escalation.attempts} attempts (${data.escalation.cause})` : undefined, + ); + + const checks = (data.ci?.failedChecks ?? []).slice(0, 8).map(formatDiscordCheck); + appendDiscordField(fields, "Checks", checks.length > 0 ? checks.join("\n") : undefined, false); + appendDiscordField( + fields, + "Blockers", + data.merge?.blockers?.length ? data.merge.blockers.slice(0, 8).join("\n") : undefined, + false, + ); + + const links = [ + ...(pr?.url ? [formatDiscordMarkdownLink("Pull request", pr.url)] : []), + ...(data.review?.url ? [formatDiscordMarkdownLink("Review", data.review.url)] : []), + ]; + appendDiscordField(fields, "Links", links.length > 0 ? links.join(" | ") : undefined, false); +} + +function appendDiscordActionField( + fields: NonNullable, + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): void { + const seen = new Set(); + const links: string[] = []; + + const prUrl = data?.subject.pr?.url; + if (prUrl) { + links.push(formatDiscordMarkdownLink("View PR", prUrl)); + seen.add(prUrl); + } + + const reviewUrl = data?.review?.url; + if (reviewUrl && !seen.has(reviewUrl)) { + links.push(formatDiscordMarkdownLink("View Review", reviewUrl)); + seen.add(reviewUrl); + } + + for (const action of actions ?? []) { + if (action.url) { + if (seen.has(action.url)) continue; + links.push(formatDiscordMarkdownLink(action.label, action.url)); + seen.add(action.url); + continue; + } + if (isAbsoluteHttpUrl(action.callbackEndpoint)) { + links.push(formatDiscordMarkdownLink(action.label, action.callbackEndpoint)); + continue; + } + links.push(`\`${action.label}\``); + } + + appendDiscordField(fields, "Actions", links.slice(0, 8).join(" | "), false); +} + +function buildDiscordComponents( + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): DiscordActionRow[] { + const seen = new Set(); + const buttons: DiscordComponentButton[] = []; + const addButton = (label: string, url: string): void => { + if (seen.has(url) || buttons.length >= 5) return; + buttons.push({ type: 2, style: 5, label: truncateUnicode(label, 80), url }); + seen.add(url); + }; + + const prUrl = data?.subject.pr?.url; + if (prUrl) addButton("View PR", prUrl); + + const reviewUrl = data?.review?.url; + if (reviewUrl) addButton("View Review", reviewUrl); + + for (const action of actions ?? []) { + if (action.url) addButton(action.label, action.url); + else if (isAbsoluteHttpUrl(action.callbackEndpoint)) + addButton(action.label, action.callbackEndpoint); + } + + return buttons.length > 0 ? [{ type: 1, components: buttons }] : []; +} + +function formatDiscordDescription( + event: OrchestratorEvent, + data: NotificationDataV3 | null, +): string { + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const description = subtitle ? `**${subtitle}**\n${event.message}` : event.message; + return truncateUnicode(description, DISCORD_EMBED_DESCRIPTION_MAX); +} + +function formatDiscordFallback(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const tone = discordToneForEvent(event); + return truncateUnicode( + `${tone.label}: ${formatDiscordTitle(event, data)} — ${event.message}`, + 2000, + ); +} + +function formatDiscordMessagePayload( + event: OrchestratorEvent, + actions?: NotifyAction[], +): DiscordMessagePayload { + const data = getNotificationDataV3(event.data); + const tone = discordToneForEvent(event); + const fields: NonNullable = []; + + appendDiscordField(fields, "Project", event.projectId); + appendDiscordField(fields, "Session", event.sessionId); + appendDiscordField(fields, "Priority", tone.label); + appendDiscordDataFields(fields, data); + appendDiscordActionField(fields, data, actions); + + const components = buildDiscordComponents(data, actions); + return { + content: formatDiscordFallback(event, data), + embeds: [ + { + title: truncateUnicode( + `${tone.emoji} ${formatDiscordTitle(event, data)}`, + DISCORD_EMBED_TITLE_MAX, + ), + description: formatDiscordDescription(event, data), + color: tone.color, + ...(data?.subject.pr?.url ? { url: data.subject.pr.url } : {}), + fields, + timestamp: event.timestamp.toISOString(), + footer: { text: "Agent Orchestrator" }, + }, + ], + ...(components.length > 0 ? { components } : {}), + allowed_mentions: { parse: [] }, + }; +} + +const SLACK_SUCCESS_TONE: SlackTone = { + emoji: ":white_check_mark:", + label: "Complete", + color: "#2EB67D", +}; + +const SLACK_PRIORITY_TONE: Record = { + urgent: { + emoji: ":rotating_light:", + label: "Urgent", + color: "#E01E5A", + }, + action: { + emoji: ":point_right:", + label: "Action required", + color: "#6157D8", + }, + warning: { + emoji: ":warning:", + label: "Warning", + color: "#ECB22E", + }, + info: { + emoji: ":information_source:", + label: "Information", + color: "#36C5F0", + }, +}; + +function escapeSlackText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\*/g, "*") + .replace(/_/g, "_") + .replace(/~/g, "~") + .replace(/`/g, "`"); +} + +function formatSlackDate(date: Date): string { + const timestamp = Math.floor(date.getTime() / 1000); + return ``; +} + +function slackToneForEvent(event: OrchestratorEvent): SlackTone { + if (event.type === "merge.ready") return { ...SLACK_SUCCESS_TONE, label: "Ready to merge" }; + if (event.type === "summary.all_complete") + return { ...SLACK_SUCCESS_TONE, label: "All complete" }; + if (event.type === "ci.failing" || event.type === "session.stuck") + return SLACK_PRIORITY_TONE.urgent; + if (event.type === "review.changes_requested") return SLACK_PRIORITY_TONE.warning; + return SLACK_PRIORITY_TONE[event.priority] ?? SLACK_PRIORITY_TONE.info; +} + +function formatSlackTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + return formatDiscordTitle(event, data); +} + +function formatSlackField( + label: string, + value: string | number | boolean | undefined | null, +): unknown { + return { + type: "mrkdwn", + text: `*${escapeSlackText(label)}*\n${escapeSlackText( + value === undefined || value === null || value === "" ? "Not available" : String(value), + )}`, + }; +} + +function buildSlackFieldBlocks( + event: OrchestratorEvent, + data: NotificationDataV3 | null, +): unknown[] { + const pr = data?.subject.pr; + const issue = data?.subject.issue; + const branch = formatDiscordBranch(data); + const fields = [ + formatSlackField("Project", event.projectId), + formatSlackField("Session", event.sessionId), + formatSlackField("Priority", slackToneForEvent(event).label), + ...(pr + ? [formatSlackField("Pull Request", `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}`)] + : []), + ...(branch ? [formatSlackField("Branch", branch)] : []), + ...(issue + ? [formatSlackField("Issue", `${issue.id}${issue.title ? ` - ${issue.title}` : ""}`)] + : []), + ...(data?.ci?.status ? [formatSlackField("CI", titleCaseStatus(data.ci.status))] : []), + ...(data?.review?.decision + ? [formatSlackField("Review", titleCaseStatus(data.review.decision))] + : []), + ...(typeof data?.merge?.ready === "boolean" + ? [formatSlackField("Merge", data.merge.ready ? "Ready" : "Not ready")] + : []), + ...(typeof data?.merge?.isBehind === "boolean" + ? [formatSlackField("Sync", data.merge.isBehind ? "Behind base" : "Up to date")] + : []), + ].slice(0, 10); + + return fields.length > 0 ? [{ type: "section", fields }] : []; +} + +function buildSlackStatusContext(data: NotificationDataV3 | null): unknown[] { + if (!data) return []; + const context: string[] = []; + + if (data.ci?.status) { + const ciEmoji = data.ci.status === "passing" ? ":white_check_mark:" : ":x:"; + const failedChecks = data.ci.failedChecks?.map((check) => escapeSlackText(check.name)) ?? []; + const failedText = failedChecks.length > 0 ? ` | Failed: ${failedChecks.join(", ")}` : ""; + context.push(`${ciEmoji} CI: ${escapeSlackText(data.ci.status)}${failedText}`); + } + + if (typeof data.merge?.conflicts === "boolean") { + context.push( + data.merge.conflicts + ? ":x: Merge conflicts detected" + : ":white_check_mark: No merge conflicts", + ); + } + + if (typeof data.review?.unresolvedThreads === "number") { + context.push(`:speech_balloon: Review threads: ${data.review.unresolvedThreads}`); + } + + if (data.merge?.blockers?.length) { + context.push( + `:no_entry: Blockers: ${data.merge.blockers.slice(0, 5).map(escapeSlackText).join(", ")}`, + ); + } + + if (context.length === 0) return []; + return [ + { + type: "context", + elements: [{ type: "mrkdwn", text: context.join(" • ") }], + }, + ]; +} + +function sanitizeSlackActionId(label: string, index: number): string { + const sanitized = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, ""); + return `ao_${sanitized ? `${sanitized}_${index}` : `action_${index}`}`; +} + +function buildSlackButton(label: string, url: string, style?: "primary" | "danger"): SlackButton { + return { + type: "button", + text: { type: "plain_text", text: truncateUnicode(label, 75), emoji: true }, + url, + ...(style ? { style } : {}), + }; +} + +function buildSlackActionElements( + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): SlackButton[] { + const elements: SlackButton[] = []; + const seenUrls = new Set(); + const prUrl = data?.subject.pr?.url; + const reviewUrl = data?.review?.url; + + if (prUrl) { + elements.push(buildSlackButton("View PR", prUrl, "primary")); + seenUrls.add(prUrl); + } + + if (reviewUrl && !seenUrls.has(reviewUrl)) { + elements.push(buildSlackButton("View Review", reviewUrl)); + seenUrls.add(reviewUrl); + } + + for (const [index, action] of (actions ?? []).entries()) { + if (action.url) { + if (seenUrls.has(action.url)) continue; + elements.push( + buildSlackButton(action.label, action.url, elements.length === 0 ? "primary" : undefined), + ); + seenUrls.add(action.url); + continue; + } + if (!action.callbackEndpoint) continue; + + const label = truncateUnicode(action.label, 75); + const lower = label.toLowerCase(); + elements.push({ + type: "button", + text: { type: "plain_text", text: label, emoji: true }, + action_id: sanitizeSlackActionId(label, index), + value: action.callbackEndpoint, + ...(lower.includes("kill") || lower.includes("cancel") ? { style: "danger" } : {}), + }); + } + + return elements.slice(0, 5); +} + +function buildSlackAttachment(event: OrchestratorEvent, actions?: NotifyAction[]): SlackAttachment { + const data = getNotificationDataV3(event.data); + const tone = slackToneForEvent(event); + const title = formatSlackTitle(event, data); + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const blocks: unknown[] = [ + { + type: "header", + text: { + type: "plain_text", + text: truncateUnicode(`${tone.emoji} ${title}`, 150), + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `${subtitle ? `*${escapeSlackText(subtitle)}*\n` : ""}${escapeSlackText(event.message)}`, + }, + }, + ...buildSlackFieldBlocks(event, data), + ...buildSlackStatusContext(data), + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Sent by Agent Orchestrator • ${formatSlackDate(event.timestamp)}`, + }, + ], + }, + ]; + + const actionElements = buildSlackActionElements(data, actions); + if (actionElements.length > 0) { + blocks.push({ + type: "actions", + elements: actionElements, + }); + } + + blocks.push({ type: "divider" }); + + return { + color: tone.color, + fallback: `${tone.label}: ${title} — ${event.message}`, + blocks, + }; +} + +function formatSlackMessagePayload( + event: OrchestratorEvent, + actions?: NotifyAction[], +): SlackMessagePayload { + const attachment = buildSlackAttachment(event, actions); + return { + markdown_text: attachment.fallback, + text: attachment.fallback, + attachments: JSON.stringify([attachment]), + unfurl_links: false, + unfurl_media: false, + }; +} + +function formatEmailSubject(event: OrchestratorEvent): string { + const data = getNotificationDataV3(event.data); + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `[AO] CI failing on PR #${pr.number}` : "[AO] CI failing"; + case "merge.ready": + return pr ? `[AO] PR #${pr.number} ready to merge` : "[AO] Merge ready"; + case "review.changes_requested": + return pr ? `[AO] Changes requested on PR #${pr.number}` : "[AO] Review changes requested"; + case "session.needs_input": + return `[AO] Agent needs input: ${event.sessionId}`; + case "session.stuck": + return `[AO] Agent stuck: ${event.sessionId}`; + case "session.killed": + case "session.exited": + return `[AO] Agent exited: ${event.sessionId}`; + case "pr.closed": + return pr ? `[AO] PR #${pr.number} closed` : "[AO] PR closed"; + case "summary.all_complete": + return "[AO] All sessions complete"; + default: + return `[AO] ${titleCaseStatus(event.type)}: ${event.sessionId}`; + } +} + +const HTML_ESCAPE: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE[char] ?? char); +} + +function formatHtmlValue(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null || value === "") return "Not available"; + return escapeHtml(String(value)); +} + +interface EmailTone { + label: string; + headerBackground: string; + accent: string; + badgeBackground: string; + badgeText: string; +} + +interface HtmlStatusCard { + label: string; + value: string; + color?: string; + background?: string; +} + +const EMAIL_TONES: Record<"info" | "action" | "warning" | "urgent" | "success", EmailTone> = { + info: { + label: "Information", + headerBackground: "#0f172a", + accent: "#2563eb", + badgeBackground: "#dbeafe", + badgeText: "#1e40af", + }, + action: { + label: "Action required", + headerBackground: "#1e3a8a", + accent: "#4f46e5", + badgeBackground: "#e0e7ff", + badgeText: "#3730a3", + }, + warning: { + label: "Warning", + headerBackground: "#78350f", + accent: "#d97706", + badgeBackground: "#fef3c7", + badgeText: "#92400e", + }, + urgent: { + label: "Urgent", + headerBackground: "#7f1d1d", + accent: "#dc2626", + badgeBackground: "#fee2e2", + badgeText: "#991b1b", + }, + success: { + label: "Complete", + headerBackground: "#064e3b", + accent: "#16a34a", + badgeBackground: "#dcfce7", + badgeText: "#166534", + }, +}; + +function emailToneForEvent(event: OrchestratorEvent): EmailTone { + if (event.type === "merge.ready" || event.type === "summary.all_complete") { + return { + ...EMAIL_TONES.success, + label: event.type === "merge.ready" ? "Ready to merge" : "All complete", + }; + } + if (event.type === "ci.failing" || event.type === "session.stuck") return EMAIL_TONES.urgent; + if (event.type === "review.changes_requested" || event.priority === "warning") { + return EMAIL_TONES.warning; + } + if (event.priority === "action") return EMAIL_TONES.action; + if (event.priority === "urgent") return EMAIL_TONES.urgent; + return EMAIL_TONES.info; +} + +function formatEmailTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI is failing on PR #${pr.number}` : "CI is failing"; + case "merge.ready": + return pr ? `PR #${pr.number} is ready to merge` : "Pull request is ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function formatEmailSubtitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + return data?.subject.pr?.title ?? data?.subject.summary ?? event.message; +} + +function formatHtmlStatusCard(card: HtmlStatusCard, fallback: EmailTone): string { + return ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
+ `; +} + +function formatHtmlStatusCards(cards: HtmlStatusCard[], tone: EmailTone): string { + if (cards.length === 0) return ""; + + const rows: string[] = []; + for (let index = 0; index < cards.length; index += 2) { + const first = cards[index]; + const second = cards[index + 1]; + rows.push(` + ${formatHtmlStatusCard(first, tone)} + ${second ? formatHtmlStatusCard(second, tone) : ''} + `); + } + + return ` + + + ${rows.join("")} +
+ + `; +} + +function formatHtmlDetailRow( + label: string, + value: string | number | boolean | undefined | null, +): string { + return ` + ${escapeHtml(label)} + ${formatHtmlValue(value)} + `; +} + +function formatHtmlDetails( + rows: Array<[string, string | number | boolean | undefined | null]>, +): string { + const renderedRows = rows + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([label, value]) => formatHtmlDetailRow(label, value)); + + if (renderedRows.length === 0) return ""; + + return `
+
Details
+ + ${renderedRows.join("")} +
+
`; +} + +function formatHtmlList(title: string, items: string[]): string { + const filtered = items.filter(Boolean); + if (filtered.length === 0) return ""; + + return `
+
${escapeHtml(title)}
+
    + ${filtered.map((item) => `
  • ${item}
  • `).join("")} +
+
`; +} + +function formatHtmlCheck(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const label = `${check.name}: ${status}`; + if (!check.url) return escapeHtml(label); + return `${escapeHtml(label)}`; +} + +function formatHtmlContextList(data: Record): string { + const items = Object.entries(data) + .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) + .slice(0, 8) + .map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(truncate(String(value)))}`); + + return formatHtmlList("Context", items); +} + +function formatHtmlActionButtons(actions: NotifyAction[] | undefined): string { + if (!actions || actions.length === 0) return ""; + + const buttons = actions + .map((action) => { + const target = action.url ?? action.callbackEndpoint; + if (!target) { + return `${escapeHtml(action.label)}`; + } + + return `${escapeHtml(action.label)}`; + }) + .join(""); + + return `
+
Actions
+ ${buttons} +
`; +} + +function buildEmailStatusCards( + event: OrchestratorEvent, + data: NotificationDataV3 | null, +): HtmlStatusCard[] { + const cards: HtmlStatusCard[] = [ + { label: "Status", value: priorityLabel(event.priority) }, + { label: "Event", value: titleCaseStatus(event.type) }, + ]; + + if (!data) return cards; + + if (data.ci?.status) { + const failing = data.ci.status === "failing"; + cards.push({ + label: "CI", + value: titleCaseStatus(data.ci.status), + color: failing ? "#991b1b" : "#166534", + background: failing ? "#fee2e2" : "#dcfce7", + }); + } + + if (data.review?.decision) { + const approved = data.review.decision === "approved"; + cards.push({ + label: "Review", + value: titleCaseStatus(data.review.decision), + color: approved ? "#166534" : "#92400e", + background: approved ? "#dcfce7" : "#fef3c7", + }); + } + + if (typeof data.merge?.ready === "boolean") { + cards.push({ + label: "Merge", + value: data.merge.ready ? "Ready" : "Not ready", + color: data.merge.ready ? "#166534" : "#92400e", + background: data.merge.ready ? "#dcfce7" : "#fef3c7", + }); + } + + if (typeof data.merge?.conflicts === "boolean") { + cards.push({ + label: "Conflicts", + value: data.merge.conflicts ? "Found" : "None", + color: data.merge.conflicts ? "#991b1b" : "#166534", + background: data.merge.conflicts ? "#fee2e2" : "#dcfce7", + }); + } + + if (typeof data.merge?.isBehind === "boolean") { + cards.push({ + label: "Sync", + value: data.merge.isBehind ? "Behind base" : "Up to date", + color: data.merge.isBehind ? "#92400e" : "#166534", + background: data.merge.isBehind ? "#fef3c7" : "#dcfce7", + }); + } + + if (typeof data.review?.unresolvedThreads === "number") { + cards.push({ + label: "Threads", + value: String(data.review.unresolvedThreads), + color: data.review.unresolvedThreads > 0 ? "#92400e" : "#166534", + background: data.review.unresolvedThreads > 0 ? "#fef3c7" : "#dcfce7", + }); + } + + return cards.slice(0, 8); +} + +function formatEmailHtml(event: OrchestratorEvent, actions?: NotifyAction[]): string { + const data = getNotificationDataV3(event.data); + const tone = emailToneForEvent(event); + const pr = data?.subject.pr; + const issue = data?.subject.issue; + const title = formatEmailTitle(event, data); + const subtitle = formatEmailSubtitle(event, data); + const branchLine = + pr?.branch && pr.baseBranch + ? `${pr.branch} -> ${pr.baseBranch}` + : (pr?.branch ?? pr?.baseBranch); + const primaryUrl = pr?.url ?? data?.review?.url; + const primaryLabel = pr?.url ? "View pull request" : data?.review?.url ? "View review" : ""; + const primaryCta = primaryUrl + ? `${escapeHtml(primaryLabel)}` + : ""; + const detailRows: Array<[string, string | number | boolean | undefined | null]> = [ + ["Project", event.projectId], + ["Session", event.sessionId], + ["Pull Request", pr ? `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}` : undefined], + ["Branch", branchLine], + ["Issue", issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined], + [ + "Transition", + data?.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined, + ], + ["Reaction", data?.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined], + [ + "Escalation", + data?.escalation + ? `${data.escalation.attempts} attempts (${data.escalation.cause})` + : undefined, + ], + ["Time", event.timestamp.toISOString()], + ]; + const checks = (data?.ci?.failedChecks ?? []).slice(0, 10).map(formatHtmlCheck); + const blockers = (data?.merge?.blockers ?? []).slice(0, 10).map(escapeHtml); + const links = [ + ...(pr?.url + ? [ + `Pull request`, + ] + : []), + ...(data?.review?.url + ? [ + `Review`, + ] + : []), + ]; + + return ` + + + + + ${escapeHtml(formatEmailSubject(event))} + + + + + + +
+ + + + + + + + ${formatHtmlStatusCards(buildEmailStatusCards(event, data), tone)} + + + + + + +
+
${escapeHtml(tone.label)}
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+
+

${escapeHtml(event.message)}

+
${primaryCta}
+
+ ${formatHtmlDetails(detailRows)} + ${formatHtmlList("Checks", checks)} + ${formatHtmlList("Blockers", blockers)} + ${formatHtmlList("Links", links)} + ${data ? "" : formatHtmlContextList(event.data)} + ${formatHtmlActionButtons(actions)} +
+
+ Sent by Agent Orchestrator. +
+
+
+ +`; +} + +function formatEmailBody(event: OrchestratorEvent, actions?: NotifyAction[]): string { + return formatEmailHtml(event, actions); +} + +function formatPostEmailBody(message: string): string { + return ` + + + + + ${escapeHtml(GMAIL_POST_SUBJECT)} + + + + + + +
+ + + + + + + +
+
Agent Orchestrator
+

Message

+
${escapeHtml(message)}
+
+ +`; +} + +function isHtmlEmailBody(body: string): boolean { + return /^\s*(?:])/i.test(body); +} + +function normalizeSlackChannel(channel: string | undefined): string | undefined { + return channel?.replace(/^#/, ""); +} + +function formatUnknownError(value: unknown): string { + if (value instanceof Error) { + const cause = (value as Error & { cause?: unknown }).cause; + if (cause !== undefined) { + const causeMessage = formatUnknownError(cause); + if (causeMessage && !value.message.includes(causeMessage)) { + return `${value.message}: ${causeMessage}`; + } + } + return value.message; + } + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function formatComposioError(err: unknown, app: ComposioApp, discordMode?: DiscordMode): Error { + const message = formatUnknownError(err); + const lower = message.toLowerCase(); + if (lower.includes("connected account") || lower.includes("could not find a connection")) { + const setupCommand = setupCommandForApp(app, discordMode); + if (app === "discord" && discordMode === "webhook") { + return new Error( + `[notifier-composio] ${message}. Run \`${setupCommand}\` to create or refresh the Discord webhook connected account for this userId.`, + ); + } + return new Error( + `[notifier-composio] ${message}. Run \`${setupCommand}\`, connect ${app} in Composio, or set connectedAccountId / userId. entityId is still supported as an alias for userId.`, + ); + } + + return err instanceof Error ? err : new Error(message); +} + +function setupCommandForApp(app: ComposioApp, discordMode?: DiscordMode): string { + if (app === "discord") { + return discordMode === "webhook" + ? "ao setup composio-discord" + : "ao setup composio-discord-bot"; + } + if (app === "gmail") return "ao setup composio-mail"; + return "ao setup composio"; +} + function buildToolArgs( app: ComposioApp, + discordMode: DiscordMode | undefined, text: string, channelId?: string, channelName?: string, emailTo?: string, + webhookUrl?: string, + emailSubject: string = GMAIL_SUBJECT, + discordPayload?: DiscordMessagePayload, + slackPayload?: SlackMessagePayload, ): Record { if (app === "slack") { - const args: Record = { text }; - if (channelId) args.channel = channelId; - else if (channelName) args.channel = channelName; + const args: Record = slackPayload + ? { ...slackPayload } + : { markdown_text: text }; + const channel = channelId ?? normalizeSlackChannel(channelName); + if (channel) args.channel = channel; return args; } if (app === "discord") { - const args: Record = { content: text }; - // Discord requires numeric channel IDs — channelName is not supported + const messagePayload: Record = discordPayload + ? { ...discordPayload } + : { + content: text, + allowed_mentions: { parse: [] }, + }; + + if (discordMode === "webhook") { + if (!webhookUrl) { + throw new Error( + '[notifier-composio] webhookUrl is required when defaultApp is "discord" and mode is "webhook"', + ); + } + const parsed = parseDiscordWebhookUrl(webhookUrl); + return { + webhook_id: parsed.webhookId, + webhook_token: parsed.webhookToken, + ...messagePayload, + }; + } + + const args: Record = { ...messagePayload }; + // Discord requires numeric channel IDs — channelName is accepted as a manual fallback. if (channelId) args.channel_id = channelId; else if (channelName) args.channel_id = channelName; + else { + throw new Error( + '[notifier-composio] channelId is required when defaultApp is "discord" and mode is "bot"', + ); + } return args; } - // gmail — emailTo is required, validated at config time return { - to: emailTo ?? "", - subject: GMAIL_SUBJECT, + recipient_email: emailTo ?? "", + subject: emailSubject, body: text, + ...(isHtmlEmailBody(text) ? { is_html: true } : {}), }; } +function resolveToolVersion( + config: Record | undefined, + app: ComposioApp, +): string | undefined { + const toolVersions = config?.["toolVersions"]; + if (toolVersions && typeof toolVersions === "object") { + const appVersion = (toolVersions as Record)[app]; + if (typeof appVersion === "string" && appVersion.trim().length > 0) { + return appVersion; + } + } + + return stringConfig(config, "toolVersion") ?? DEFAULT_TOOL_VERSION[app]; +} + +function resolveToolSlug(app: ComposioApp, discordMode: DiscordMode | undefined): string { + if (app === "discord" && discordMode === "webhook") return DISCORD_WEBHOOK_TOOL_SLUG; + return APP_TOOL_SLUG[app]; +} + export function create(config?: Record): Notifier { const apiKey = - (typeof config?.composioApiKey === "string" ? config.composioApiKey : undefined) ?? - process.env.COMPOSIO_API_KEY; + resolveEnvReference(stringConfig(config, "composioApiKey")) ?? process.env.COMPOSIO_API_KEY; const defaultApp: ComposioApp = typeof config?.defaultApp === "string" && VALID_APPS.has(config.defaultApp) ? (config.defaultApp as ComposioApp) : "slack"; - const channelName = typeof config?.channelName === "string" ? config.channelName : undefined; - const channelId = typeof config?.channelId === "string" ? config.channelId : undefined; - const emailTo = typeof config?.emailTo === "string" ? config.emailTo : undefined; + const channelName = stringConfig(config, "channelName"); + const channelId = stringConfig(config, "channelId"); + const webhookUrl = resolveEnvReference(stringConfig(config, "webhookUrl")); + const discordMode = resolveDiscordMode(config, defaultApp, webhookUrl); + const userId = + stringConfig(config, "userId") ?? + stringConfig(config, "entityId") ?? + process.env.COMPOSIO_USER_ID ?? + process.env.COMPOSIO_ENTITY_ID ?? + DEFAULT_COMPOSIO_USER_ID; + const emailTo = stringConfig(config, "emailTo"); + const toolVersion = resolveToolVersion(config, defaultApp); + const forceSkipVersionCheck = boolConfig(config, "dangerouslySkipVersionCheck"); + const connectedAccountId = stringConfig(config, "connectedAccountId"); - // Internal: allows tests to inject a mock client without mocking composio-core const clientOverride = - config?._clientOverride !== undefined && - config._clientOverride !== null && - typeof (config._clientOverride as ComposioToolkit).executeAction === "function" - ? (config._clientOverride as ComposioToolkit) + config?._clientOverride !== undefined && config._clientOverride !== null + ? config._clientOverride : undefined; + if (clientOverride !== undefined && !isComposioToolsClient(clientOverride)) { + throw new Error("[notifier-composio] _clientOverride must expose tools.execute()"); + } + if (typeof config?.defaultApp === "string" && !VALID_APPS.has(config.defaultApp)) { throw new Error( `[notifier-composio] Invalid defaultApp: "${config.defaultApp}". Must be one of: slack, discord, gmail`, @@ -159,13 +1573,21 @@ export function create(config?: Record): Notifier { throw new Error('[notifier-composio] emailTo is required when defaultApp is "gmail"'); } - let client: ComposioToolkit | null | undefined = clientOverride; + if (defaultApp === "discord" && discordMode === "webhook" && !webhookUrl) { + throw new Error( + '[notifier-composio] webhookUrl is required when defaultApp is "discord" and mode is "webhook"', + ); + } + + let client: ComposioToolsClient | null | undefined = clientOverride as + | ComposioToolsClient + | undefined; let warnedNoKey = false; + let warnedSkipVersion = false; let sdkMissing = false; - async function getClient(): Promise { - // If a client override was injected, always use it - if (clientOverride) return clientOverride; + async function getClient(): Promise { + if (clientOverride) return clientOverride as ComposioToolsClient; if (!apiKey) { if (!warnedNoKey) { @@ -183,9 +1605,8 @@ export function create(config?: Record): Notifier { client = await loadComposioSDK(apiKey); if (client === null) { sdkMissing = true; - // eslint-disable-next-line no-console console.warn( - "[notifier-composio] composio-core package is not installed — notifications will be no-ops. Run: npm install composio-core", + "[notifier-composio] @composio/core package is not installed — notifications will be no-ops.", ); return null; } @@ -195,15 +1616,29 @@ export function create(config?: Record): Notifier { } async function executeWithTimeout( - composio: ComposioToolkit, + composio: ComposioToolsClient, action: string, - params: Record, + args: Record, ): Promise { const timeoutMs = 30_000; const timeoutSignal = AbortSignal.timeout(timeoutMs); + const executeParams: ComposioExecuteParams = { + userId, + arguments: args, + ...(connectedAccountId ? { connectedAccountId } : {}), + ...(toolVersion ? { version: toolVersion } : { dangerouslySkipVersionCheck: true }), + ...(forceSkipVersionCheck ? { dangerouslySkipVersionCheck: true } : {}), + }; - const actionPromise = composio.executeAction({ action, params }); - // Prevent unhandled rejection if the timeout fires and actionPromise later rejects + if (!toolVersion && !warnedSkipVersion) { + console.warn( + `[notifier-composio] No toolVersion configured for ${defaultApp}; using Composio latest-version execution.`, + ); + warnedSkipVersion = true; + } + + const actionPromise = composio.tools.execute(action, executeParams); + // Prevent unhandled rejection if the timeout fires and actionPromise later rejects. actionPromise.catch(() => {}); const result = await Promise.race([ @@ -221,11 +1656,21 @@ export function create(config?: Record): Notifier { { once: true }, ); }), - ]); + ]).catch((err: unknown) => { + throw formatComposioError(err, defaultApp, discordMode); + }); - if (!result.successful) { + if (result.successful === false) { throw new Error( - `[notifier-composio] Composio action ${action} failed: ${result.error ?? "unknown error"}`, + `[notifier-composio] Composio action ${action} failed: ${formatUnknownError(result.error ?? "unknown error")}`, + ); + } + } + + function assertGmailConnectedAccount(): void { + if (defaultApp === "gmail" && !connectedAccountId) { + throw new Error( + '[notifier-composio] connectedAccountId is required when defaultApp is "gmail". Connect Gmail in Composio, then run `ao setup composio-mail`, or set notifiers..connectedAccountId.', ); } } @@ -236,10 +1681,26 @@ export function create(config?: Record): Notifier { async notify(event: OrchestratorEvent): Promise { const composio = await getClient(); if (!composio) return; + assertGmailConnectedAccount(); - const text = formatNotifyText(event); - const toolSlug = APP_TOOL_SLUG[defaultApp]; - const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo); + const text = defaultApp === "gmail" ? formatEmailBody(event) : formatNotifyText(event); + const emailSubject = defaultApp === "gmail" ? formatEmailSubject(event) : undefined; + const discordPayload = + defaultApp === "discord" ? formatDiscordMessagePayload(event) : undefined; + const slackPayload = defaultApp === "slack" ? formatSlackMessagePayload(event) : undefined; + const toolSlug = resolveToolSlug(defaultApp, discordMode); + const args = buildToolArgs( + defaultApp, + discordMode, + text, + channelId, + channelName, + emailTo, + webhookUrl, + emailSubject, + discordPayload, + slackPayload, + ); await executeWithTimeout(composio, toolSlug, args); }, @@ -247,10 +1708,30 @@ export function create(config?: Record): Notifier { async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { const composio = await getClient(); if (!composio) return; + assertGmailConnectedAccount(); - const text = formatActionsText(event, actions); - const toolSlug = APP_TOOL_SLUG[defaultApp]; - const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo); + const text = + defaultApp === "gmail" + ? formatEmailBody(event, actions) + : formatActionsText(event, actions); + const emailSubject = defaultApp === "gmail" ? formatEmailSubject(event) : undefined; + const discordPayload = + defaultApp === "discord" ? formatDiscordMessagePayload(event, actions) : undefined; + const slackPayload = + defaultApp === "slack" ? formatSlackMessagePayload(event, actions) : undefined; + const toolSlug = resolveToolSlug(defaultApp, discordMode); + const args = buildToolArgs( + defaultApp, + discordMode, + text, + channelId, + channelName, + emailTo, + webhookUrl, + emailSubject, + discordPayload, + slackPayload, + ); await executeWithTimeout(composio, toolSlug, args); }, @@ -258,16 +1739,31 @@ export function create(config?: Record): Notifier { async post(message: string, context?: NotifyContext): Promise { const composio = await getClient(); if (!composio) return null; + assertGmailConnectedAccount(); const channel = context?.channel ?? channelId ?? channelName; - const toolSlug = APP_TOOL_SLUG[defaultApp]; + const slackChannel = normalizeSlackChannel(channel); + const toolSlug = resolveToolSlug(defaultApp, discordMode); const args: Record = defaultApp === "gmail" - ? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message } + ? { + recipient_email: emailTo ?? "", + subject: GMAIL_POST_SUBJECT, + body: formatPostEmailBody(message), + is_html: true, + } : defaultApp === "discord" - ? { content: message, ...(channel ? { channel_id: channel } : {}) } - : { text: message, ...(channel ? { channel } : {}) }; + ? buildToolArgs( + defaultApp, + discordMode, + message, + discordMode === "bot" ? channel : channelId, + channelName, + emailTo, + webhookUrl, + ) + : { markdown_text: message, ...(slackChannel ? { channel: slackChannel } : {}) }; await executeWithTimeout(composio, toolSlug, args); return null; diff --git a/packages/plugins/notifier-dashboard/package.json b/packages/plugins/notifier-dashboard/package.json new file mode 100644 index 000000000..5bb7ac9c0 --- /dev/null +++ b/packages/plugins/notifier-dashboard/package.json @@ -0,0 +1,44 @@ +{ + "name": "@aoagents/ao-plugin-notifier-dashboard", + "version": "0.6.0", + "description": "Notifier plugin: AO dashboard notifications", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/notifier-dashboard" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@aoagents/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/notifier-dashboard/src/index.test.ts b/packages/plugins/notifier-dashboard/src/index.test.ts new file mode 100644 index 000000000..862f95118 --- /dev/null +++ b/packages/plugins/notifier-dashboard/src/index.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildSessionTransitionNotificationData, + getDashboardNotificationStorePath, + readDashboardNotifications, + type OrchestratorEvent, +} from "@aoagents/ao-core"; +import { create, manifest } from "./index.js"; + +let tempDir: string | null = null; + +function makeConfigPath(): string { + tempDir = mkdtempSync(join(tmpdir(), "ao-dashboard-plugin-")); + return join(tempDir, "agent-orchestrator.yaml"); +} + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "session.needs_input", + priority: "action", + sessionId: "worker-1", + projectId: "demo", + timestamp: new Date("2026-05-13T12:00:00.000Z"), + message: "Agent needs input", + data: buildSessionTransitionNotificationData({ + eventType: "session.needs_input", + sessionId: "worker-1", + projectId: "demo", + context: { + pr: { + number: 1, + url: "https://github.com/acme/app/pull/1", + title: "Demo PR", + branch: "demo/pr", + baseBranch: "main", + owner: "acme", + repo: "app", + isDraft: false, + }, + issueId: null, + issueTitle: null, + summary: "Demo session", + branch: "demo/pr", + }, + oldStatus: "working", + newStatus: "needs_input", + }), + ...overrides, + }; +} + +afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + vi.restoreAllMocks(); +}); + +describe("notifier-dashboard", () => { + it("has dashboard notifier metadata", () => { + expect(manifest.name).toBe("dashboard"); + expect(manifest.slot).toBe("notifier"); + }); + + it("persists notifications to the config-specific dashboard store", async () => { + const configPath = makeConfigPath(); + const notifier = create({ configPath, limit: 50 }); + + await notifier.notify(makeEvent()); + + const records = readDashboardNotifications(configPath); + expect(records).toHaveLength(1); + expect(records[0].event.sessionId).toBe("worker-1"); + expect(records[0].event.data).toMatchObject({ + schemaVersion: 3, + subject: { pr: { url: "https://github.com/acme/app/pull/1" } }, + }); + }); + + it("persists actions with notifications", async () => { + const configPath = makeConfigPath(); + const notifier = create({ configPath }); + + await notifier.notifyWithActions?.(makeEvent(), [ + { label: "Open PR", url: "https://github.com/acme/app/pull/1" }, + ]); + + const records = readDashboardNotifications(configPath); + expect(records[0].actions).toEqual([ + { label: "Open PR", url: "https://github.com/acme/app/pull/1" }, + ]); + }); + + it("retains only the configured limit", async () => { + const configPath = makeConfigPath(); + const notifier = create({ configPath, limit: 2 }); + + await notifier.notify(makeEvent({ id: "evt-1" })); + await notifier.notify(makeEvent({ id: "evt-2" })); + await notifier.notify(makeEvent({ id: "evt-3" })); + + expect(readDashboardNotifications(configPath, 50).map((record) => record.event.id)).toEqual([ + "evt-2", + "evt-3", + ]); + }); + + it("warns and no-ops when configPath is missing", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create(); + + await notifier.notify(makeEvent()); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No configPath available")); + }); + + it("uses the expected store path shape", () => { + const configPath = makeConfigPath(); + expect(getDashboardNotificationStorePath(configPath)).toContain( + "dashboard-notifications.jsonl", + ); + }); +}); diff --git a/packages/plugins/notifier-dashboard/src/index.ts b/packages/plugins/notifier-dashboard/src/index.ts new file mode 100644 index 000000000..e8296ecc5 --- /dev/null +++ b/packages/plugins/notifier-dashboard/src/index.ts @@ -0,0 +1,53 @@ +import { + appendDashboardNotification, + normalizeDashboardNotificationLimit, + type Notifier, + type NotifyAction, + type OrchestratorEvent, + type PluginModule, +} from "@aoagents/ao-core"; + +export const manifest = { + name: "dashboard", + slot: "notifier" as const, + description: "Notifier plugin: AO dashboard notifications", + version: "0.1.0", +}; + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function create(config?: Record): Notifier { + const configPath = stringValue(config?.configPath); + const limit = normalizeDashboardNotificationLimit(config?.limit); + let warnedMissingConfigPath = false; + + function persist(event: OrchestratorEvent, actions?: NotifyAction[]): void { + if (!configPath) { + if (!warnedMissingConfigPath) { + console.warn( + "[notifier-dashboard] No configPath available - dashboard notifications will be no-ops", + ); + warnedMissingConfigPath = true; + } + return; + } + + appendDashboardNotification(configPath, event, actions, { limit }); + } + + return { + name: "dashboard", + + async notify(event: OrchestratorEvent): Promise { + persist(event); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + persist(event, actions); + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-dashboard/tsconfig.json b/packages/plugins/notifier-dashboard/tsconfig.json new file mode 100644 index 000000000..e434eed50 --- /dev/null +++ b/packages/plugins/notifier-dashboard/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/plugins/notifier-desktop/src/index.test.ts b/packages/plugins/notifier-desktop/src/index.test.ts index 6abf9bae1..1f7755c41 100644 --- a/packages/plugins/notifier-desktop/src/index.test.ts +++ b/packages/plugins/notifier-desktop/src/index.test.ts @@ -1,22 +1,37 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; import type { OrchestratorEvent, NotifyAction } from "@aoagents/ao-core"; // Mock node:child_process vi.mock("node:child_process", () => ({ execFile: vi.fn(), + execFileSync: vi.fn(), +})); + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), })); // Mock node:os vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/Users/test"), platform: vi.fn(() => "darwin"), })); -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { platform } from "node:os"; import { manifest, create, escapeAppleScript } from "./index.js"; const mockExecFile = execFile as unknown as Mock; +const mockExecFileSync = execFileSync as unknown as Mock; +const mockExistsSync = existsSync as unknown as Mock; const mockPlatform = platform as unknown as Mock; +const originalProcessPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + +function setProcessPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} function makeEvent(overrides: Partial = {}): OrchestratorEvent { return { @@ -36,6 +51,14 @@ describe("notifier-desktop", () => { beforeEach(() => { vi.clearAllMocks(); mockPlatform.mockReturnValue("darwin"); + setProcessPlatform("darwin"); + mockExistsSync.mockReturnValue(false); + // Default: terminal-notifier not available (osascript fallback) + mockExecFileSync.mockImplementation(() => { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); mockExecFile.mockImplementation((..._args: unknown[]) => { // execFile may be called as (cmd, args, cb) or (cmd, args, opts, cb). // Pick whichever trailing arg is the callback so both shapes work. @@ -46,6 +69,12 @@ describe("notifier-desktop", () => { }); }); + afterEach(() => { + if (originalProcessPlatform) { + Object.defineProperty(process, "platform", originalProcessPlatform); + } + }); + describe("manifest", () => { it("has correct metadata", () => { expect(manifest.name).toBe("desktop"); @@ -95,7 +124,7 @@ describe("notifier-desktop", () => { expect(mockExecFile.mock.calls[0][1][0]).toBe("-e"); }); - it("includes session ID in title", async () => { + it("includes session ID in notification subtitle", async () => { const notifier = create(); await notifier.notify(makeEvent({ sessionId: "backend-5" })); @@ -119,12 +148,12 @@ describe("notifier-desktop", () => { expect(script).toContain("URGENT"); }); - it("uses 'Agent Orchestrator' prefix for non-urgent priority", async () => { + it("uses event-aware titles for non-urgent priority", async () => { const notifier = create(); await notifier.notify(makeEvent({ priority: "action" })); const script = mockExecFile.mock.calls[0][1][1] as string; - expect(script).toContain("Agent Orchestrator"); + expect(script).toContain("Session Spawned"); }); it("includes sound for urgent notifications", async () => { @@ -179,6 +208,50 @@ describe("notifier-desktop", () => { expect(script).toContain('\\"quotes\\"'); expect(script).toContain("\\\\backslash"); }); + + it("formats v3 pull request context into a compact desktop summary", async () => { + const notifier = create(); + await notifier.notify( + makeEvent({ + type: "merge.ready", + priority: "action", + projectId: "demo", + sessionId: "demo-agent-29", + message: "PR #1579 is ready to merge", + data: { + schemaVersion: 3, + subject: { + session: { id: "demo-agent-29", projectId: "demo" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + branch: "ao/demo-notifier-harness", + baseBranch: "main", + }, + issue: { id: "AO-1579", title: "Make AO notification payloads API-grade" }, + }, + ci: { status: "passing" }, + review: { decision: "approved" }, + merge: { ready: true, conflicts: false }, + transition: { kind: "pr_state", from: "approved", to: "mergeable" }, + }, + }), + ); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("PR #1579 ready to merge"); + expect(script).toContain("Normalize AO notifier payloads"); + expect(script).toContain("demo · demo-agent-29 · PR #1579"); + expect(script).toContain("PR #1579"); + expect(script).toContain("AO-1579"); + expect(script).toContain("Branch: ao/demo-notifier-harness → main"); + expect(script).toContain("CI: Passing"); + expect(script).toContain("Review: Approved"); + expect(script).toContain("Merge: Ready"); + expect(script).toContain("Conflicts: None"); + expect(script).toContain("Transition: approved → mergeable"); + }); }); describe("notify on Linux", () => { @@ -319,4 +392,222 @@ describe("notifier-desktop", () => { await expect(notifier.notify(makeEvent())).rejects.toThrow("osascript not found"); }); }); + + describe("terminal-notifier on macOS", () => { + beforeEach(() => { + // terminal-notifier is available + mockExecFileSync.mockReturnValue(Buffer.from("/usr/local/bin/terminal-notifier\n")); + }); + + it("uses terminal-notifier when available", async () => { + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile).toHaveBeenCalledOnce(); + expect(mockExecFile.mock.calls[0][0]).toBe("terminal-notifier"); + }); + + it("passes -title, -subtitle, and -message args", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ sessionId: "s-1", message: "hello" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-title"); + expect(args).toContain("-subtitle"); + expect(args).toContain("-message"); + expect(args[args.indexOf("-subtitle") + 1]).toBe("my-project · s-1 · Info"); + expect(args[args.indexOf("-message") + 1]).toContain("hello"); + }); + + it("passes session deep link with dashboardUrl when configured", async () => { + const notifier = create({ dashboardUrl: "http://localhost:8080" }); + await notifier.notify(makeEvent()); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-open"); + expect(args[args.indexOf("-open") + 1]).toBe( + "http://localhost:8080/projects/my-project/sessions/app-1", + ); + }); + + it("does not pass -open when dashboardUrl is not configured", async () => { + const notifier = create(); + await notifier.notify(makeEvent()); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("-open"); + }); + + it("passes -sound default for urgent notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-sound"); + expect(args[args.indexOf("-sound") + 1]).toBe("default"); + }); + + it("does not pass -sound for non-urgent notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "info" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("-sound"); + }); + + it("respects sound=false config", async () => { + const notifier = create({ sound: false }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("-sound"); + }); + + it("falls back to osascript when terminal-notifier is not found", async () => { + mockExecFileSync.mockImplementation(() => { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("osascript"); + }); + + it("does not use terminal-notifier on Linux", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); + }); + + it("uses terminal-notifier for notifyWithActions too", async () => { + const notifier = create({ dashboardUrl: "http://localhost:3000" }); + const actions: NotifyAction[] = [{ label: "View", url: "https://example.com" }]; + await notifier.notifyWithActions!(makeEvent(), actions); + + expect(mockExecFile.mock.calls[0][0]).toBe("terminal-notifier"); + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-open"); + }); + }); + + describe("AO Notifier.app backend", () => { + beforeEach(() => { + mockExistsSync.mockImplementation((path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier"), + ); + }); + + it("uses AO Notifier.app before terminal-notifier in auto mode", async () => { + mockExecFileSync.mockReturnValue(Buffer.from("/usr/local/bin/terminal-notifier\n")); + const notifier = create({ dashboardUrl: "http://localhost:3000" }); + await notifier.notify(makeEvent({ message: "native app" })); + + expect(mockExecFile.mock.calls[0][0]).toBe( + "/Users/test/Applications/AO Notifier.app/Contents/MacOS/ao-notifier", + ); + expect(mockExecFile.mock.calls[0][1][0]).toBe("--notify-base64"); + }); + + it("passes event metadata and default open URL to AO Notifier.app", async () => { + const notifier = create({ backend: "ao-app", dashboardUrl: "http://localhost:3001" }); + await notifier.notify(makeEvent({ id: "evt-native", sessionId: "s-9" })); + + const encoded = mockExecFile.mock.calls[0][1][1] as string; + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as { + notificationId: string; + threadId: string; + subtitle: string; + defaultOpenUrl: string; + event: { id: string; sessionId: string }; + }; + expect(payload.notificationId).toMatch(/^evt-native\./); + expect(payload.threadId).toBe("ao.notifications"); + expect(payload.subtitle).toBe("my-project · s-9 · Info"); + expect(payload.defaultOpenUrl).toBe("http://localhost:3001/projects/my-project/sessions/s-9"); + expect(payload.event).toMatchObject({ id: "evt-native", sessionId: "s-9" }); + }); + + it("scopes native notification sequence to each notifier instance", async () => { + const first = create({ backend: "ao-app" }); + const second = create({ backend: "ao-app" }); + + await first.notify(makeEvent({ id: "evt-first" })); + await second.notify(makeEvent({ id: "evt-second" })); + + const firstEncoded = mockExecFile.mock.calls[0][1][1] as string; + const secondEncoded = mockExecFile.mock.calls[1][1][1] as string; + const firstPayload = JSON.parse(Buffer.from(firstEncoded, "base64").toString("utf-8")) as { + notificationId: string; + }; + const secondPayload = JSON.parse(Buffer.from(secondEncoded, "base64").toString("utf-8")) as { + notificationId: string; + }; + + expect(firstPayload.notificationId).toMatch(/^evt-first\..*\.1$/); + expect(secondPayload.notificationId).toMatch(/^evt-second\..*\.1$/); + }); + + it("passes URL actions to AO Notifier.app", async () => { + const notifier = create({ backend: "ao-app" }); + const actions: NotifyAction[] = [ + { label: "Open PR", url: "https://github.com/example/pr/1" }, + { label: "Kill", callbackEndpoint: "/api/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const encoded = mockExecFile.mock.calls[0][1][1] as string; + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as { + body: string; + actions: Array<{ label: string; url?: string; callbackEndpoint?: string }>; + }; + expect(payload.actions).toEqual([ + { label: "Open PR", url: "https://github.com/example/pr/1" }, + ]); + expect(payload.body).toContain("Kill"); + expect(payload.body).not.toContain("Open PR"); + }); + + it("passes callback actions to AO Notifier.app when they resolve against dashboardUrl", async () => { + const notifier = create({ backend: "ao-app", dashboardUrl: "http://localhost:3000" }); + const actions: NotifyAction[] = [ + { label: "Kill", callbackEndpoint: "/api/sessions/app-1/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const encoded = mockExecFile.mock.calls[0][1][1] as string; + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as { + body: string; + actions: Array<{ label: string; callbackEndpoint?: string }>; + }; + expect(payload.actions).toEqual([ + { label: "Kill", callbackEndpoint: "http://localhost:3000/api/sessions/app-1/kill" }, + ]); + expect(payload.body).not.toContain("Kill"); + }); + + it("fails when backend ao-app is configured but the app is missing", async () => { + mockExistsSync.mockReturnValue(false); + const notifier = create({ backend: "ao-app" }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("ao setup desktop"); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it("does not use a placeholder AO Notifier.app in auto mode", async () => { + mockExistsSync.mockImplementation( + (path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier") || + path.endsWith("AO Notifier.app/Contents/Resources/ao-notifier-placeholder"), + ); + const notifier = create(); + + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("osascript"); + }); + }); }); diff --git a/packages/plugins/notifier-desktop/src/index.ts b/packages/plugins/notifier-desktop/src/index.ts index c29e6bb0c..cf25ac307 100644 --- a/packages/plugins/notifier-desktop/src/index.ts +++ b/packages/plugins/notifier-desktop/src/index.ts @@ -1,12 +1,17 @@ -import { execFile } from "node:child_process"; -import { platform } from "node:os"; +import { execFile, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; import { escapeAppleScript, + getNotificationDataV3, + isMac, type PluginModule, type Notifier, type OrchestratorEvent, type NotifyAction, type EventPriority, + type NotificationDataV3, } from "@aoagents/ao-core"; function xmlEscape(s: string): string { @@ -49,6 +54,27 @@ export const manifest = { // Re-export for backwards compatibility export { escapeAppleScript } from "@aoagents/ao-core"; +type DesktopBackend = "auto" | "ao-app" | "terminal-notifier" | "osascript"; +const PLACEHOLDER_MARKER_NAME = "ao-notifier-placeholder"; + +interface MacDeliveryOptions { + backend: DesktopBackend; + appPath: string; + useTerminalNotifier: boolean; +} + +interface DesktopNotificationContent { + title: string; + subtitle?: string; + body: string; +} + +interface NativeActionPayload { + label: string; + url?: string; + callbackEndpoint?: string; +} + /** * Map event priority to notification urgency: * - urgent: sound alert @@ -60,52 +86,413 @@ function shouldPlaySound(priority: EventPriority, soundEnabled: boolean): boolea return priority === "urgent"; } +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +function eventTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + function formatTitle(event: OrchestratorEvent): string { - const prefix = event.priority === "urgent" ? "URGENT" : "Agent Orchestrator"; - return `${prefix} [${event.sessionId}]`; + const data = getNotificationDataV3(event.data); + const title = eventTitle(event, data); + if (event.priority === "urgent") return `URGENT: ${title}`; + if (event.priority === "warning") return `Warning: ${title}`; + return title; } -function formatMessage(event: OrchestratorEvent): string { - return event.message; +function formatSubtitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const segments = [event.projectId, event.sessionId]; + const pr = data?.subject.pr; + if (pr) segments.push(`PR #${pr.number}`); + else segments.push(titleCaseStatus(event.priority)); + return truncate(segments.join(" · "), 120); } -function formatActionsMessage(event: OrchestratorEvent, actions: NotifyAction[]): string { - const actionLabels = actions.map((a) => a.label).join(" | "); - return `${event.message}\n\nActions: ${actionLabels}`; +function formatBranch(data: NotificationDataV3 | null): string | undefined { + const pr = data?.subject.pr; + if (pr?.branch && pr.baseBranch) return `${pr.branch} → ${pr.baseBranch}`; + return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch; +} + +function appendLine(lines: string[], value: string | undefined, maxLength = 180): void { + const trimmed = value?.trim(); + if (!trimmed) return; + lines.push(truncate(trimmed, maxLength)); +} + +function formatStatusLine(data: NotificationDataV3): string | undefined { + const segments: string[] = []; + + if (data.ci?.status) { + const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? []; + const failedText = failedChecks.length > 0 ? ` (${failedChecks.slice(0, 3).join(", ")})` : ""; + segments.push(`CI: ${titleCaseStatus(data.ci.status)}${failedText}`); + } + + if (data.review?.decision) { + const threads = + typeof data.review.unresolvedThreads === "number" + ? `, ${data.review.unresolvedThreads} threads` + : ""; + segments.push(`Review: ${titleCaseStatus(data.review.decision)}${threads}`); + } + + if (typeof data.merge?.ready === "boolean") { + segments.push(`Merge: ${data.merge.ready ? "Ready" : "Not ready"}`); + } + + if (typeof data.merge?.conflicts === "boolean") { + segments.push(`Conflicts: ${data.merge.conflicts ? "Found" : "None"}`); + } + + return segments.length > 0 ? segments.join(" · ") : undefined; +} + +function formatSubjectLine(data: NotificationDataV3 | null): string | undefined { + if (!data) return undefined; + const pr = data.subject.pr; + const issue = data.subject.issue; + const segments: string[] = []; + + if (pr) segments.push(`PR #${pr.number}`); + if (issue) segments.push(issue.id); + + return segments.length > 0 ? segments.join(" · ") : undefined; +} + +function formatDetailLine(label: string, value: string | undefined): string | undefined { + return value ? `${label}: ${value}` : undefined; +} + +function formatActionLine( + actions: NotifyAction[] | undefined, + hiddenActionIndexes: Set = new Set(), +): string | undefined { + const visibleActions = (actions ?? []).filter((_, index) => !hiddenActionIndexes.has(index)); + if (visibleActions.length === 0) return undefined; + return `Actions: ${visibleActions.map((action) => action.label).join(" · ")}`; +} + +function formatBody( + event: OrchestratorEvent, + actions?: NotifyAction[], + options: { hiddenActionIndexes?: Set } = {}, +): string { + const data = getNotificationDataV3(event.data); + const lines: string[] = []; + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const branch = formatBranch(data); + + if (subtitle && subtitle !== event.message) appendLine(lines, subtitle, 150); + appendLine(lines, event.message, 180); + appendLine(lines, formatDetailLine("Context", formatSubjectLine(data)), 160); + if (data) appendLine(lines, formatDetailLine("Status", formatStatusLine(data)), 180); + appendLine(lines, formatDetailLine("Branch", branch), 140); + if (data?.transition) + appendLine(lines, `Transition: ${data.transition.from} → ${data.transition.to}`, 120); + appendLine(lines, formatActionLine(actions, options.hiddenActionIndexes), 160); + + return lines.join("\n"); +} + +function formatContent( + event: OrchestratorEvent, + actions?: NotifyAction[], + options: { hiddenActionIndexes?: Set } = {}, +): DesktopNotificationContent { + const data = getNotificationDataV3(event.data); + return { + title: formatTitle(event), + subtitle: formatSubtitle(event, data), + body: formatBody(event, actions, options), + }; +} + +function defaultMacAppPath(): string { + return join(homedir(), "Applications", "AO Notifier.app"); +} + +function macAppExecutable(appPath: string): string { + return join(appPath, "Contents", "MacOS", "ao-notifier"); +} + +function macAppPlaceholderMarker(appPath: string): string { + return join(appPath, "Contents", "Resources", PLACEHOLDER_MARKER_NAME); +} + +function nativeNotificationId(event: OrchestratorEvent, sequence: number): string { + return `${event.id}.${Date.now()}.${process.pid}.${sequence}`; +} + +function nativeThreadId(): string { + return "ao.notifications"; +} + +function detectAoNotifierApp(appPath: string): boolean { + return existsSync(macAppExecutable(appPath)) && !existsSync(macAppPlaceholderMarker(appPath)); +} + +function parseBackend(value: unknown): DesktopBackend { + if ( + value === "auto" || + value === "ao-app" || + value === "terminal-notifier" || + value === "osascript" + ) { + return value; + } + return "auto"; +} + +function dashboardSessionUrl( + dashboardUrl: string | undefined, + event: OrchestratorEvent, +): string | undefined { + if (!dashboardUrl) return undefined; + try { + const base = new URL(dashboardUrl); + base.pathname = `/projects/${encodeURIComponent(event.projectId)}/sessions/${encodeURIComponent(event.sessionId)}`; + base.search = ""; + base.hash = ""; + return base.toString(); + } catch { + return dashboardUrl; + } +} + +function firstUrlAction(actions: NotifyAction[] | undefined): string | undefined { + return actions?.find((action) => typeof action.url === "string")?.url; +} + +function isAbsoluteHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function resolveCallbackEndpoint( + callbackEndpoint: string | undefined, + dashboardUrl: string | undefined, +): string | undefined { + if (isAbsoluteHttpUrl(callbackEndpoint)) return callbackEndpoint; + if (!callbackEndpoint || !dashboardUrl) return undefined; + try { + const resolved = new URL(callbackEndpoint, dashboardUrl); + return resolved.protocol === "http:" || resolved.protocol === "https:" + ? resolved.toString() + : undefined; + } catch { + return undefined; + } +} + +function nativeActionPayload( + action: NotifyAction, + dashboardUrl: string | undefined, +): NativeActionPayload | undefined { + if (typeof action.url === "string") { + return { label: action.label, url: action.url }; + } + const callbackEndpoint = resolveCallbackEndpoint(action.callbackEndpoint, dashboardUrl); + return callbackEndpoint ? { label: action.label, callbackEndpoint } : undefined; +} + +function nativeActionIndexes( + actions: NotifyAction[] | undefined, + dashboardUrl: string | undefined, +): Set { + const indexes = new Set(); + for (const [index, action] of (actions ?? []).entries()) { + if (nativeActionPayload(action, dashboardUrl)) indexes.add(index); + } + return indexes; +} + +function nativeActionPayloads( + actions: NotifyAction[] | undefined, + dashboardUrl: string | undefined, +): NativeActionPayload[] { + return (actions ?? []) + .map((action) => nativeActionPayload(action, dashboardUrl)) + .filter((action): action is NativeActionPayload => Boolean(action)) + .slice(0, 4); +} + +function firstActionTarget( + actions: NotifyAction[] | undefined, + dashboardUrl: string | undefined, +): string | undefined { + for (const action of actions ?? []) { + const target = action.url ?? resolveCallbackEndpoint(action.callbackEndpoint, dashboardUrl); + if (target) return target; + } + return undefined; +} + +function primaryOpenUrl( + event: OrchestratorEvent, + dashboardUrl: string | undefined, + actions?: NotifyAction[], +): string | undefined { + return ( + dashboardSessionUrl(dashboardUrl, event) ?? + getNotificationDataV3(event.data)?.subject.pr?.url ?? + firstUrlAction(actions) ?? + firstActionTarget(actions, dashboardUrl) + ); +} + +/** Check once at create() time whether terminal-notifier is available. */ +function detectTerminalNotifier(): boolean { + try { + execFileSync("terminal-notifier", ["--version"], { stdio: "ignore", windowsHide: true }); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } } /** - * Send a desktop notification using osascript (macOS) or notify-send (Linux). - * Falls back gracefully if neither is available. + * Send a desktop notification using terminal-notifier / osascript (macOS) or + * notify-send (Linux). Falls back gracefully if neither is available. * - * Note: Desktop notifications do not support click-through URLs natively. - * On macOS, osascript's `display notification` lacks URL support. - * Consider `terminal-notifier` for click-to-open if needed in the future. + * On macOS, when `terminal-notifier` is installed, notifications support + * click-to-open: clicking the banner opens `openUrl` in the default browser. + * Without it, the osascript fallback is used (no click-through). */ function sendNotification( - title: string, - message: string, - options: { sound: boolean; isUrgent: boolean }, + content: DesktopNotificationContent, + event: OrchestratorEvent, + options: { + sound: boolean; + isUrgent: boolean; + mac: MacDeliveryOptions; + notificationId: string; + openUrl?: string; + actions?: NativeActionPayload[]; + fallbackContent?: DesktopNotificationContent; + }, ): Promise { return new Promise((resolve, reject) => { const os = platform(); if (os === "darwin") { - const safeTitle = escapeAppleScript(title); - const safeMessage = escapeAppleScript(message); - const soundClause = options.sound ? ' sound name "default"' : ""; - const script = `display notification "${safeMessage}" with title "${safeTitle}"${soundClause}`; - execFile("osascript", ["-e", script], (err) => { - if (err) reject(err); - else resolve(); - }); + const backend = + options.mac.backend === "auto" + ? detectAoNotifierApp(options.mac.appPath) + ? "ao-app" + : options.mac.useTerminalNotifier + ? "terminal-notifier" + : "osascript" + : options.mac.backend; + + if (backend === "ao-app") { + if (!detectAoNotifierApp(options.mac.appPath)) { + reject(new Error("AO Notifier.app is not installed. Run: ao setup desktop")); + return; + } + + const payload = { + notificationId: options.notificationId, + threadId: nativeThreadId(), + title: content.title, + subtitle: content.subtitle, + body: content.body, + sound: options.sound, + defaultOpenUrl: options.openUrl, + event: { + id: event.id, + type: event.type, + priority: event.priority, + sessionId: event.sessionId, + projectId: event.projectId, + timestamp: event.timestamp.toISOString(), + }, + actions: (options.actions ?? []).map((action) => ({ + label: action.label, + url: action.url, + callbackEndpoint: action.callbackEndpoint, + })), + }; + const encoded = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64"); + execFile(macAppExecutable(options.mac.appPath), ["--notify-base64", encoded], (err) => { + if (err) reject(err); + else resolve(); + }); + } else if (backend === "terminal-notifier") { + const fallbackContent = options.fallbackContent ?? content; + const args = ["-title", fallbackContent.title, "-message", fallbackContent.body]; + if (fallbackContent.subtitle) { + args.push("-subtitle", fallbackContent.subtitle); + } + if (options.openUrl) { + args.push("-open", options.openUrl); + } + if (options.sound) { + args.push("-sound", "default"); + } + execFile("terminal-notifier", args, (err) => { + if (err) reject(err); + else resolve(); + }); + } else { + const fallbackContent = options.fallbackContent ?? content; + const safeTitle = escapeAppleScript(fallbackContent.title); + const safeSubtitle = fallbackContent.subtitle + ? ` subtitle "${escapeAppleScript(fallbackContent.subtitle)}"` + : ""; + const safeMessage = escapeAppleScript(fallbackContent.body); + const soundClause = options.sound ? ' sound name "default"' : ""; + const script = `display notification "${safeMessage}" with title "${safeTitle}"${safeSubtitle}${soundClause}`; + execFile("osascript", ["-e", script], (err) => { + if (err) reject(err); + else resolve(); + }); + } } else if (os === "linux") { // Linux urgency is driven by event priority, not the macOS sound config const args: string[] = []; if (options.isUrgent) { args.push("--urgency=critical"); } - args.push(title, message); + const fallbackContent = options.fallbackContent ?? content; + args.push(fallbackContent.title, fallbackContent.body); execFile("notify-send", args, (err) => { if (err) reject(err); else resolve(); @@ -114,7 +501,12 @@ function sendNotification( // WinRT toast via PowerShell — no third-party deps. Encode the script // as UTF-16LE base64 so we never fight with PowerShell's argument // tokenizer over quotes, special chars, or newlines in the toast XML. - const script = buildWindowsToastScript(title, message, options.sound); + const fallbackContent = options.fallbackContent ?? content; + const script = buildWindowsToastScript( + fallbackContent.subtitle ?? fallbackContent.title, + fallbackContent.body, + options.sound, + ); const encoded = Buffer.from(script, "utf16le").toString("base64"); execFile( "powershell.exe", @@ -140,27 +532,61 @@ function sendNotification( } export function create(config?: Record): Notifier { + let nativeNotificationSequence = 0; const soundEnabled = typeof config?.sound === "boolean" ? config.sound : true; + const dashboardUrl = typeof config?.dashboardUrl === "string" ? config.dashboardUrl : undefined; + const backend = parseBackend(config?.backend); + const appPath = typeof config?.appPath === "string" ? config.appPath : defaultMacAppPath(); + const hasTerminalNotifier = + isMac() && (backend === "auto" || backend === "terminal-notifier") + ? detectTerminalNotifier() + : false; + const mac = { + backend, + appPath, + useTerminalNotifier: hasTerminalNotifier, + }; + const nextNativeNotificationId = (event: OrchestratorEvent): string => { + nativeNotificationSequence += 1; + return nativeNotificationId(event, nativeNotificationSequence); + }; return { name: "desktop", async notify(event: OrchestratorEvent): Promise { - const title = formatTitle(event); - const message = formatMessage(event); + const content = formatContent(event); const sound = shouldPlaySound(event.priority, soundEnabled); const isUrgent = event.priority === "urgent"; - await sendNotification(title, message, { sound, isUrgent }); + await sendNotification(content, event, { + sound, + isUrgent, + mac, + notificationId: nextNativeNotificationId(event), + openUrl: primaryOpenUrl(event, dashboardUrl), + }); }, async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { - // Desktop notifications cannot display interactive action buttons. - // Actions are rendered as text labels in the notification body as a fallback. - const title = formatTitle(event); - const message = formatActionsMessage(event, actions); + const nativeActions = nativeActionPayloads(actions, dashboardUrl); + const content = formatContent(event, actions, { + hiddenActionIndexes: + backend === "ao-app" || (backend === "auto" && detectAoNotifierApp(appPath)) + ? nativeActionIndexes(actions, dashboardUrl) + : undefined, + }); + const fallbackContent = formatContent(event, actions); const sound = shouldPlaySound(event.priority, soundEnabled); const isUrgent = event.priority === "urgent"; - await sendNotification(title, message, { sound, isUrgent }); + await sendNotification(content, event, { + sound, + isUrgent, + mac, + notificationId: nextNativeNotificationId(event), + openUrl: primaryOpenUrl(event, dashboardUrl, actions), + actions: nativeActions, + fallbackContent, + }); }, }; } diff --git a/packages/plugins/notifier-discord/src/index.test.ts b/packages/plugins/notifier-discord/src/index.test.ts index 1151c3976..925ec2604 100644 --- a/packages/plugins/notifier-discord/src/index.test.ts +++ b/packages/plugins/notifier-discord/src/index.test.ts @@ -16,6 +16,14 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "ao-5", projectId: "ao" } }, + ...overrides, + }; +} + describe("notifier-discord", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -50,11 +58,11 @@ describe("notifier-discord", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.username).toBe("Agent Orchestrator"); + expect(body.allowed_mentions).toEqual({ parse: [] }); expect(body.embeds).toHaveLength(1); const embed = body.embeds[0]; - expect(embed.title).toContain("ao-5"); - expect(embed.title).toContain("reaction.escalated"); + expect(embed.title).toContain("Reaction Escalated"); expect(embed.description).toBe("CI failed after 5 retries"); expect(embed.color).toBe(0xed4245); // red for urgent expect(embed.timestamp).toBe("2026-03-20T12:00:00.000Z"); @@ -71,7 +79,8 @@ describe("notifier-discord", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); const fields = body.embeds[0].fields; expect(fields).toContainEqual(expect.objectContaining({ name: "Project", value: "ao" })); - expect(fields).toContainEqual(expect.objectContaining({ name: "Priority", value: "urgent" })); + expect(fields).toContainEqual(expect.objectContaining({ name: "Session", value: "ao-5" })); + expect(fields).toContainEqual(expect.objectContaining({ name: "Priority", value: "Urgent" })); }); it("includes PR link when available", async () => { @@ -79,7 +88,16 @@ describe("notifier-discord", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); - await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } })); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "ao-5", projectId: "ao" }, + pr: { number: 42, url: "https://github.com/org/repo/pull/42" }, + }, + }), + }), + ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); const prField = body.embeds[0].fields.find((f: { name: string }) => f.name === "Pull Request"); @@ -91,11 +109,41 @@ describe("notifier-discord", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); - await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + await notifier.notify(makeEvent({ data: makeV3Data({ ci: { status: "passing" } }) })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); const ciField = body.embeds[0].fields.find((f: { name: string }) => f.name === "CI"); - expect(ciField.value).toContain("passing"); + expect(ciField.value).toContain("Passing"); + }); + + it("encodes closing parentheses in markdown link URLs", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + ci: { + status: "failing", + failedChecks: [ + { + name: "build(test)", + status: "completed", + conclusion: "failure", + url: "https://github.com/org/repo/actions/runs/1?q=(abc)", + }, + ], + }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const checksField = body.embeds[0].fields.find((f: { name: string }) => f.name === "Checks"); + expect(checksField.value).toContain( + "[buildtest: completed/failure](https://github.com/org/repo/actions/runs/1?q=(abc%29)", + ); }); it("notifyWithActions includes action links", async () => { @@ -124,6 +172,7 @@ describe("notifier-discord", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.content).toBe("Session ao-5 completed successfully"); + expect(body.allowed_mentions).toEqual({ parse: [] }); expect(body.embeds).toBeUndefined(); }); @@ -131,7 +180,10 @@ describe("notifier-discord", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); vi.stubGlobal("fetch", fetchMock); - const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc", username: "AO Bot" }); + const notifier = create({ + webhookUrl: "https://discord.com/api/webhooks/123/abc", + username: "AO Bot", + }); await notifier.notify(makeEvent()); const body = JSON.parse(fetchMock.mock.calls[0][1].body); @@ -187,13 +239,57 @@ describe("notifier-discord", () => { await notifier.notify(makeEvent({ priority: "info" })); let body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.embeds[0].color).toBe(0x57f287); // green + expect(body.embeds[0].color).toBe(0x3498db); // blue await notifier.notify(makeEvent({ priority: "warning" })); body = JSON.parse(fetchMock.mock.calls[1][1].body); expect(body.embeds[0].color).toBe(0xfee75c); // yellow }); + it("uses success color and professional fields for merge-ready events", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify( + makeEvent({ + type: "merge.ready", + priority: "action", + message: "PR #1579 is ready to merge", + data: makeV3Data({ + subject: { + session: { id: "ao-5", projectId: "ao" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/org/repo/pull/1579", + branch: "feat/notifiers", + baseBranch: "main", + }, + }, + ci: { status: "passing" }, + review: { decision: "approved" }, + merge: { ready: true, conflicts: false, isBehind: false }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const embed = body.embeds[0]; + expect(embed.title).toContain("PR #1579 ready to merge"); + expect(embed.color).toBe(0x57f287); + expect(embed.url).toBe("https://github.com/org/repo/pull/1579"); + expect(embed.description).toContain("Normalize AO notifier payloads"); + expect(embed.fields).toContainEqual(expect.objectContaining({ name: "CI" })); + expect(embed.fields).toContainEqual( + expect.objectContaining({ name: "Review", value: "Approved" }), + ); + expect(embed.fields).toContainEqual(expect.objectContaining({ name: "Merge", value: "Ready" })); + expect(embed.fields).toContainEqual( + expect.objectContaining({ name: "Sync", value: "Up to date" }), + ); + }); + it("handles 204 No Content as success", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }); vi.stubGlobal("fetch", fetchMock); diff --git a/packages/plugins/notifier-discord/src/index.ts b/packages/plugins/notifier-discord/src/index.ts index 7134fa2e2..564815fb1 100644 --- a/packages/plugins/notifier-discord/src/index.ts +++ b/packages/plugins/notifier-discord/src/index.ts @@ -1,4 +1,6 @@ import { + getNotificationDataV3, + recordActivityEvent, validateUrl, type PluginModule, type Notifier, @@ -6,6 +8,8 @@ import { type NotifyAction, type NotifyContext, type EventPriority, + type NotificationDataV3, + type NotificationCICheck, CI_STATUS, } from "@aoagents/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig } from "@aoagents/ao-core/utils"; @@ -17,75 +21,309 @@ export const manifest = { version: "0.1.0", }; -// Discord embed color codes (decimal) -const PRIORITY_COLOR: Record = { - urgent: 0xed4245, // red - action: 0x5865f2, // blurple - warning: 0xfee75c, // yellow - info: 0x57f287, // green -}; - -const PRIORITY_EMOJI: Record = { - urgent: "\u{1F6A8}", // rotating light - action: "\u{1F449}", // point right - warning: "\u{26A0}\u{FE0F}", // warning - info: "\u{2139}\u{FE0F}", // info -}; - -const DISCORD_WEBHOOK_URL_RE = - /^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//; +const DISCORD_WEBHOOK_URL_RE = /^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//; +const DISCORD_EMBED_TITLE_MAX = 256; const EMBED_DESCRIPTION_MAX = 4096; +const DISCORD_FIELD_NAME_MAX = 256; +const DISCORD_FIELD_VALUE_MAX = 1024; +const DISCORD_MAX_FIELDS = 25; interface DiscordEmbed { title: string; description: string; color: number; + url?: string; fields?: { name: string; value: string; inline?: boolean }[]; timestamp?: string; footer?: { text: string }; } +interface DiscordTone { + emoji: string; + label: string; + color: number; +} + +const SUCCESS_TONE: DiscordTone = { + emoji: "\u{2705}", + label: "Complete", + color: 0x57f287, +}; + +const PRIORITY_TONE: Record = { + urgent: { + emoji: "\u{1F6A8}", + label: "Urgent", + color: 0xed4245, + }, + action: { + emoji: "\u{1F449}", + label: "Action required", + color: 0x5865f2, + }, + warning: { + emoji: "\u{26A0}\u{FE0F}", + label: "Warning", + color: 0xfee75c, + }, + info: { + emoji: "\u{2139}\u{FE0F}", + label: "Information", + color: 0x3498db, + }, +}; + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value; +} + +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function toneForEvent(event: OrchestratorEvent): DiscordTone { + if (event.type === "merge.ready") return { ...SUCCESS_TONE, label: "Ready to merge" }; + if (event.type === "summary.all_complete") return { ...SUCCESS_TONE, label: "All complete" }; + if (event.type === "ci.failing" || event.type === "session.stuck") return PRIORITY_TONE.urgent; + if (event.type === "review.changes_requested") return PRIORITY_TONE.warning; + return PRIORITY_TONE[event.priority] ?? PRIORITY_TONE.info; +} + +function eventTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function fieldValue(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null || value === "") return "Not available"; + return truncate(String(value), DISCORD_FIELD_VALUE_MAX); +} + +function appendField( + fields: NonNullable, + name: string, + value: string | number | boolean | undefined | null, + inline = true, +): void { + if (value === undefined || value === null || value === "") return; + if (fields.length >= DISCORD_MAX_FIELDS) return; + fields.push({ + name: truncate(name, DISCORD_FIELD_NAME_MAX), + value: fieldValue(value), + inline, + }); +} + +function formatMarkdownLink(label: string, url: string): string { + return `[${label.replace(/[\][()]/g, "")}](${url.replace(/\)/g, "%29")})`; +} + +function formatBranch(data: NotificationDataV3 | null): string | undefined { + const pr = data?.subject.pr; + if (pr?.branch && pr.baseBranch) return `${pr.branch} -> ${pr.baseBranch}`; + return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch; +} + +function formatCheck(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const label = `${check.name}: ${status}`; + return check.url ? formatMarkdownLink(label, check.url) : label; +} + +function isAbsoluteHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function appendDataFields( + fields: NonNullable, + data: NotificationDataV3 | null, +): void { + if (!data) return; + + const pr = data.subject.pr; + const issue = data.subject.issue; + const branch = formatBranch(data); + + appendField( + fields, + "Pull Request", + pr + ? `${formatMarkdownLink(`#${pr.number}`, pr.url)}${pr.title ? ` - ${pr.title}` : ""}` + : undefined, + false, + ); + appendField(fields, "Branch", branch); + appendField( + fields, + "Issue", + issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined, + ); + appendField(fields, "CI", data.ci?.status ? formatCiStatus(data) : undefined); + appendField( + fields, + "Review", + data.review?.decision ? titleCaseStatus(data.review.decision) : undefined, + ); + appendField(fields, "Review Threads", data.review?.unresolvedThreads); + appendField( + fields, + "Merge", + typeof data.merge?.ready === "boolean" ? (data.merge.ready ? "Ready" : "Not ready") : undefined, + ); + appendField( + fields, + "Conflicts", + typeof data.merge?.conflicts === "boolean" + ? data.merge.conflicts + ? "Found" + : "None" + : undefined, + ); + appendField( + fields, + "Sync", + typeof data.merge?.isBehind === "boolean" + ? data.merge.isBehind + ? "Behind base" + : "Up to date" + : undefined, + ); + appendField( + fields, + "Transition", + data.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined, + ); + appendField( + fields, + "Reaction", + data.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined, + ); + appendField( + fields, + "Escalation", + data.escalation ? `${data.escalation.attempts} attempts (${data.escalation.cause})` : undefined, + ); + + const checks = (data.ci?.failedChecks ?? []).slice(0, 8).map(formatCheck); + appendField(fields, "Checks", checks.length > 0 ? checks.join("\n") : undefined, false); + appendField( + fields, + "Blockers", + data.merge?.blockers?.length ? data.merge.blockers.slice(0, 8).join("\n") : undefined, + false, + ); + + const links = [ + ...(pr?.url ? [formatMarkdownLink("Pull request", pr.url)] : []), + ...(data.review?.url ? [formatMarkdownLink("Review", data.review.url)] : []), + ]; + appendField(fields, "Links", links.length > 0 ? links.join(" | ") : undefined, false); +} + +function formatCiStatus(data: NotificationDataV3): string { + if (!data.ci?.status) return ""; + const ciEmoji = data.ci.status === CI_STATUS.PASSING ? "\u{2705}" : "\u{274C}"; + const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? []; + const failedCheckText = failedChecks.length > 0 ? `\nFailed: ${failedChecks.join(", ")}` : ""; + return `${ciEmoji} ${titleCaseStatus(data.ci.status)}${failedCheckText}`; +} + +function appendActionField( + fields: NonNullable, + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): void { + const seen = new Set(); + const links: string[] = []; + + const prUrl = data?.subject.pr?.url; + if (prUrl) { + links.push(formatMarkdownLink("View PR", prUrl)); + seen.add(prUrl); + } + + const reviewUrl = data?.review?.url; + if (reviewUrl && !seen.has(reviewUrl)) { + links.push(formatMarkdownLink("View Review", reviewUrl)); + seen.add(reviewUrl); + } + + for (const action of actions ?? []) { + if (action.url) { + if (seen.has(action.url)) continue; + links.push(formatMarkdownLink(action.label, action.url)); + seen.add(action.url); + continue; + } + if (isAbsoluteHttpUrl(action.callbackEndpoint)) { + links.push(formatMarkdownLink(action.label, action.callbackEndpoint)); + continue; + } + links.push(`\`${action.label}\``); + } + + appendField(fields, "Actions", links.slice(0, 8).join(" | "), false); +} + +function formatDescription(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const description = subtitle ? `**${subtitle}**\n${event.message}` : event.message; + return truncate(description, EMBED_DESCRIPTION_MAX); +} + function buildEmbed(event: OrchestratorEvent, actions?: NotifyAction[]): DiscordEmbed { - const emoji = PRIORITY_EMOJI[event.priority]; - const description = - event.message.length > EMBED_DESCRIPTION_MAX - ? event.message.slice(0, EMBED_DESCRIPTION_MAX - 1) + "\u2026" - : event.message; + const data = getNotificationDataV3(event.data); + const tone = toneForEvent(event); + const fields: NonNullable = []; + + appendField(fields, "Project", event.projectId); + appendField(fields, "Session", event.sessionId); + appendField(fields, "Priority", tone.label); + appendDataFields(fields, data); + appendActionField(fields, data, actions); + const embed: DiscordEmbed = { - title: `${emoji} ${event.type} — ${event.sessionId}`, - description, - color: PRIORITY_COLOR[event.priority], - fields: [ - { name: "Project", value: event.projectId, inline: true }, - { name: "Priority", value: event.priority, inline: true }, - ], + title: truncate(`${tone.emoji} ${eventTitle(event, data)}`, DISCORD_EMBED_TITLE_MAX), + description: formatDescription(event, data), + color: tone.color, + ...(data?.subject.pr?.url ? { url: data.subject.pr.url } : {}), + fields, timestamp: event.timestamp.toISOString(), footer: { text: "Agent Orchestrator" }, }; - // Add PR link if available - const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; - if (prUrl) { - embed.fields!.push({ name: "Pull Request", value: `[View PR](${prUrl})`, inline: false }); - } - - // Add CI status if available - const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; - if (ciStatus) { - const ciEmoji = ciStatus === CI_STATUS.PASSING ? "\u{2705}" : "\u{274C}"; - embed.fields!.push({ name: "CI", value: `${ciEmoji} ${ciStatus}`, inline: true }); - } - - // Add actions as a field - if (actions && actions.length > 0) { - const actionLinks = actions.map((a) => { - if (a.url) return `[${a.label}](${a.url})`; - return `\`${a.label}\``; - }); - embed.fields!.push({ name: "Actions", value: actionLinks.join(" | "), inline: false }); - } - return embed; } @@ -129,7 +367,20 @@ async function postWithRetry( // Rate-limit budget exhausted — fail immediately rather than falling through // to the error retry path (which would compound the two counters). const body = await response.text().catch(() => ""); - lastError = new Error(`Discord webhook rate-limited (HTTP 429)${body ? `: ${body.trim()}` : ""}`); + recordActivityEvent({ + source: "notifier", + kind: "notifier.rate_limited", + level: "warn", + summary: `Discord webhook rate-limit retry budget exhausted`, + data: { + plugin: "notifier-discord", + status: 429, + rateLimitRetries, + }, + }); + lastError = new Error( + `Discord webhook rate-limited (HTTP 429)${body ? `: ${body.trim()}` : ""}`, + ); throw lastError; } @@ -166,26 +417,27 @@ export function create(config?: Record): Notifier { if (!webhookUrl) { console.warn( "[notifier-discord] No webhookUrl configured.\n" + - " Set it in agent-orchestrator.yaml under notifiers.discord.webhookUrl\n" + - " Create a webhook: Discord Server Settings > Integrations > Webhooks > New Webhook", + " Set it in agent-orchestrator.yaml under notifiers.discord.webhookUrl\n" + + " Create a webhook: Discord Server Settings > Integrations > Webhooks > New Webhook", ); } else { validateUrl(webhookUrl, "notifier-discord"); if (!DISCORD_WEBHOOK_URL_RE.test(webhookUrl)) { console.warn( "[notifier-discord] webhookUrl does not match expected Discord webhook format.\n" + - " Expected: https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...", + " Expected: https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...", ); } } // Discord requires thread_id as a URL query param, not in the JSON body - const effectiveUrl = webhookUrl && threadId - ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${encodeURIComponent(threadId)}` - : webhookUrl; + const effectiveUrl = + webhookUrl && threadId + ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${encodeURIComponent(threadId)}` + : webhookUrl; function buildPayload(embeds: DiscordEmbed[]): Record { - const payload: Record = { username, embeds }; + const payload: Record = { username, embeds, allowed_mentions: { parse: [] } }; if (avatarUrl) payload.avatar_url = avatarUrl; return payload; } @@ -207,7 +459,11 @@ export function create(config?: Record): Notifier { async post(message: string, _context?: NotifyContext): Promise { if (!effectiveUrl) return null; - const payload: Record = { username, content: message }; + const payload: Record = { + username, + content: message, + allowed_mentions: { parse: [] }, + }; if (avatarUrl) payload.avatar_url = avatarUrl; // thread_id is already passed as a URL query param via effectiveUrl await postWithRetry(effectiveUrl, payload, retries, retryDelayMs); diff --git a/packages/plugins/notifier-openclaw/README.md b/packages/plugins/notifier-openclaw/README.md index 0ffcb757f..d74504b5e 100644 --- a/packages/plugins/notifier-openclaw/README.md +++ b/packages/plugins/notifier-openclaw/README.md @@ -8,12 +8,25 @@ OpenClaw notifier plugin for AO escalation events. ao setup openclaw ``` -This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., in CI/CD pipelines or automation scripts): +This interactive wizard auto-detects your OpenClaw gateway, lets you reuse or change the URL, OpenClaw config path, and routing values, then writes the AO config. For non-interactive use (e.g., in CI/CD pipelines or automation scripts): ```bash -ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive +ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --non-interactive ``` +AO does not generate the token or write shell-profile exports. Local setup reads `hooks.token` from your OpenClaw config. For a remote OpenClaw gateway, you can pass `--token` and AO will store that token in `agent-orchestrator.yaml`. + +Useful follow-up commands: + +```bash +ao setup openclaw --refresh +ao setup openclaw --status +``` + +Interactive setup asks which notification priorities OpenClaw should receive. +For scriptable setup, pass `--routing-preset urgent-only`, `urgent-action`, or +`all`. + ## Required OpenClaw config (`openclaw.json`) ```json @@ -34,7 +47,7 @@ notifiers: openclaw: plugin: openclaw url: http://127.0.0.1:18789/hooks/agent - token: ${OPENCLAW_HOOKS_TOKEN} + openclawConfigPath: ~/.openclaw/openclaw.json ``` ## Behavior @@ -46,8 +59,8 @@ notifiers: ## Token rotation 1. Rotate `hooks.token` in OpenClaw. -2. Update `OPENCLAW_HOOKS_TOKEN` used by AO. -3. Verify old token returns `401` and new token returns `200`. +2. Restart OpenClaw so it picks up the new config. +3. Run `ao setup openclaw --status` to verify the new token. ## Known limitation (Phase 0) diff --git a/packages/plugins/notifier-openclaw/src/activity-events.test.ts b/packages/plugins/notifier-openclaw/src/activity-events.test.ts new file mode 100644 index 000000000..da89f5e2f --- /dev/null +++ b/packages/plugins/notifier-openclaw/src/activity-events.test.ts @@ -0,0 +1,174 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers notifier.auth_failed (MUST) and notifier.unreachable (SHOULD) — + * the two failure shapes RCA needs to distinguish. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OrchestratorEvent } from "@aoagents/ao-core"; + +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { create } from "./index.js"; + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "reaction.escalated", + priority: "urgent", + sessionId: "ao-5", + projectId: "ao", + timestamp: new Date("2026-03-08T12:00:00Z"), + message: "Reaction escalated", + data: {}, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + delete process.env.OPENCLAW_HOOKS_TOKEN; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("notifier.auth_failed (MUST emit)", () => { + it("emits on 401 (distinct from notifier.unreachable on ECONNREFUSED)", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/OpenClaw rejected the auth token/); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notifier.auth_failed", + level: "error", + sessionId: "ao-5", + data: expect.objectContaining({ + plugin: "notifier-openclaw", + status: 401, + }), + }), + ); + }); + + it("emits on 403", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("forbidden") }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "notifier.auth_failed", + data: expect.objectContaining({ status: 403 }), + }), + ); + }); +}); + +describe("notifier.unreachable (SHOULD emit)", () => { + it.each(["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ENETUNREACH"])( + "emits on %s (distinct from notifier.auth_failed)", + async (code) => { + const fetchMock = vi.fn().mockRejectedValue(new Error(`fetch failed: ${code}`)); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/Can't reach OpenClaw gateway/); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notifier.unreachable", + level: "warn", + sessionId: "ao-5", + data: expect.objectContaining({ + plugin: "notifier-openclaw", + errorMessage: expect.stringContaining(code), + }), + }), + ); + + // Critically: should NOT also fire auth_failed — distinct shapes. + const authFailedCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.auth_failed", + ); + expect(authFailedCalls).toHaveLength(0); + }, + ); + + it.each(["ETIMEDOUT", "ENOTFOUND", "ENETUNREACH"])( + "does not emit on transient %s when a retry succeeds", + async (code) => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error(`fetch failed: ${code}`)) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 1, retryDelayMs: 0 }); + await notifier.notify(makeEvent()); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const unreachableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.unreachable", + ); + expect(unreachableCalls).toHaveLength(0); + }, + ); + + it.each(["ETIMEDOUT", "ENOTFOUND", "ENETUNREACH"])( + "emits on transient %s only after retry budget is exhausted", + async (code) => { + const fetchMock = vi.fn().mockRejectedValue(new Error(`fetch failed: ${code}`)); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 1, retryDelayMs: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/Can't reach OpenClaw gateway/); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const unreachableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.unreachable", + ); + expect(unreachableCalls).toHaveLength(1); + expect(unreachableCalls[0]?.[0].data).toMatchObject({ + errorMessage: expect.stringContaining(code), + }); + }, + ); + + it("does not emit unreachable for unrelated network errors", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed: certificate expired")); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok", retries: 0 }); + await expect(notifier.notify(makeEvent())).rejects.toThrow(/fetch failed: certificate expired/); + + const unreachableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "notifier.unreachable", + ); + expect(unreachableCalls).toHaveLength(0); + }); +}); diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts index 1e690e6e5..6cfc03597 100644 --- a/packages/plugins/notifier-openclaw/src/index.test.ts +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import type { NotifyAction, OrchestratorEvent } from "@aoagents/ao-core"; +import { + buildCIFailureNotificationData, + buildSessionTransitionNotificationData, + type NotificationEventContext, + type NotifyAction, + type OrchestratorEvent, +} from "@aoagents/ao-core"; import { create, manifest } from "./index.js"; function makeEvent(overrides: Partial = {}): OrchestratorEvent { @@ -19,6 +25,23 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } +const prContext: NotificationEventContext = { + pr: { + number: 1579, + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + 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", +}; + describe("notifier-openclaw", () => { let tempConfigDir: string; let tempConfigPath: string; @@ -57,23 +80,38 @@ describe("notifier-openclaw", () => { it("uses token from OPENCLAW_HOOKS_TOKEN env", async () => { process.env.OPENCLAW_HOOKS_TOKEN = "env-token"; + const missingOpenClawConfigPath = join(tempConfigDir, "missing-openclaw.json"); const fetchMock = vi.fn().mockResolvedValue({ ok: true }); vi.stubGlobal("fetch", fetchMock); - const notifier = create(); + const notifier = create({ openclawConfigPath: missingOpenClawConfigPath }); await notifier.notify(makeEvent()); const headers = fetchMock.mock.calls[0][1].headers; expect(headers["Authorization"]).toBe("Bearer env-token"); }); + it("uses hooks token from configured OpenClaw config path", async () => { + const openclawConfigPath = join(tempConfigDir, "openclaw.json"); + writeFileSync(openclawConfigPath, JSON.stringify({ hooks: { token: "config-token" } })); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ openclawConfigPath }); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer config-token"); + }); + it("warns and sends without Authorization when token missing", async () => { + const missingOpenClawConfigPath = join(tempConfigDir, "missing-openclaw.json"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const fetchMock = vi.fn().mockResolvedValue({ ok: true }); vi.stubGlobal("fetch", fetchMock); - const notifier = create(); + const notifier = create({ openclawConfigPath: missingOpenClawConfigPath }); await notifier.notify(makeEvent()); const headers = fetchMock.mock.calls[0][1].headers as Record; @@ -103,16 +141,123 @@ describe("notifier-openclaw", () => { expect(body.sessionKey).toBe("hook:ao:ao-12-x"); }); - it("notifyWithActions appends action labels", async () => { + it("notifyWithActions appends action links", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }); vi.stubGlobal("fetch", fetchMock); const notifier = create({ token: "tok" }); - const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }]; + const actions: NotifyAction[] = [ + { label: "Open dashboard", url: "http://localhost:3000" }, + { label: "Acknowledge", callbackEndpoint: "http://localhost:3000/api/ack" }, + ]; await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.message).toContain("Actions available: retry, kill"); + expect(body.message).toContain("**Actions**"); + expect(body.message).toContain("- [Open dashboard](http://localhost:3000)"); + expect(body.message).toContain("- [Acknowledge](http://localhost:3000/api/ack)"); + }); + + it("escapes markdown-sensitive labels and URLs in links", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + const actions: NotifyAction[] = [ + { + label: "Open [prod] (now) *please*", + url: "https://github.com/org/repo/pull/1?a=(test)", + }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain( + "- [Open \\[prod\\] \\(now\\) \\*please\\*](https://github.com/org/repo/pull/1?a=%28test%29)", + ); + }); + + it("formats v3 CI notifications as a compact OpenClaw brief", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify( + makeEvent({ + 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: prContext, + failedChecks: [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks", + }, + ], + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("**AO ACTION** `ci.failing`"); + expect(body.message).toContain("**Pull Request**"); + expect(body.message).toContain( + "[#1579 - Normalize AO notifier payloads](https://github.com/ComposioHQ/agent-orchestrator/pull/1579)", + ); + expect(body.message).toContain("**Checks**"); + expect(body.message).toContain( + "- [typecheck](https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks): `failed/FAILURE`", + ); + expect(body.message).not.toContain("Context: {"); + }); + + it("formats v3 merge notifications with status and links", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify( + makeEvent({ + 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: prContext, + oldStatus: "approved", + newStatus: "mergeable", + enrichment: { + state: "open", + ciStatus: "passing", + reviewDecision: "approved", + mergeable: true, + title: "Normalize AO notifier payloads", + hasConflicts: false, + isBehind: false, + }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("- Transition: `approved` -> `mergeable`"); + expect(body.message).toContain("- CI: `passing`"); + expect(body.message).toContain("- Review: `approved`"); + expect(body.message).toContain("- Merge ready: yes"); + expect(body.message).toContain( + "- [Pull request](https://github.com/ComposioHQ/agent-orchestrator/pull/1579)", + ); }); it("post uses context sessionId when provided", async () => { @@ -190,9 +335,7 @@ describe("notifier-openclaw", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ token: "tok", retries: 0 }); - await expect(notifier.notify(makeEvent())).rejects.toThrow( - "Can't reach OpenClaw gateway", - ); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Can't reach OpenClaw gateway"); }); it("records success telemetry when a notification is sent", async () => { diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index fcd5ae6cf..16ffd4b3c 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -2,27 +2,37 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from " import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { + getNotificationDataV3, type EventPriority, + type NotificationCICheck, + type NotificationDataV3, type Notifier, type NotifyAction, type NotifyContext, type OrchestratorEvent, type PluginModule, getObservabilityBaseDir, + recordActivityEvent, } from "@aoagents/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@aoagents/ao-core/utils"; /** - * Read the hooks token from ~/.openclaw/openclaw.json as a fallback for - * daemon contexts where the shell profile (and OPENCLAW_HOOKS_TOKEN) isn't - * sourced. This file is written by `ao setup openclaw` and lives outside - * the project directory so it's never committed to version control. + * Read the hooks token from OpenClaw's config. AO treats OpenClaw as the + * owner of hooks.token; setup only points the notifier at this file. */ -function readTokenFromOpenClawConfig(): string | undefined { +function expandHomePath(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +function readTokenFromOpenClawConfig(configPath?: string): string | undefined { try { - const configPath = join(homedir(), ".openclaw", "openclaw.json"); - if (!existsSync(configPath)) return undefined; - const raw = readFileSync(configPath, "utf-8"); + const resolvedPath = expandHomePath( + configPath ?? join(homedir(), ".openclaw", "openclaw.json"), + ); + if (!existsSync(resolvedPath)) return undefined; + const raw = readFileSync(resolvedPath, "utf-8"); const config = JSON.parse(raw) as Record; const token = (config.hooks as Record | undefined)?.token; return typeof token === "string" && token ? token : undefined; @@ -39,6 +49,13 @@ export const manifest = { }; const DEFAULT_TIMEOUT_MS = 10_000; +const UNREACHABLE_NETWORK_ERROR_CODES = [ + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "ENETUNREACH", +] as const; +type UnreachableNetworkErrorCode = (typeof UNREACHABLE_NETWORK_ERROR_CODES)[number]; type WakeMode = "now" | "next-heartbeat"; @@ -117,6 +134,10 @@ function recordHealthFailure(path: string | null, error: unknown): void { writeHealthSummary(path, summary); } +function getUnreachableNetworkErrorCode(error: Error): UnreachableNetworkErrorCode | undefined { + return UNREACHABLE_NETWORK_ERROR_CODES.find((code) => error.message.includes(code)); +} + async function postWithRetry( url: string, payload: OpenClawWebhookPayload, @@ -130,6 +151,7 @@ async function postWithRetry( for (let attempt = 0; attempt <= retries; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + let shouldRethrowResponseError = false; try { const response = await fetch(url, { method: "POST", @@ -143,17 +165,33 @@ async function postWithRetry( const body = await response.text(); if (response.status === 401 || response.status === 403) { + // User-actionable: distinct from generic 5xx — token expired or wrong. + recordActivityEvent({ + sessionId: context.sessionId, + source: "notifier", + kind: "notifier.auth_failed", + level: "error", + summary: `OpenClaw rejected auth token (HTTP ${response.status})`, + data: { + plugin: "notifier-openclaw", + status: response.status, + url, + fixHint: "ao setup openclaw", + }, + }); lastError = new Error( `OpenClaw rejected the auth token (HTTP ${response.status}).\n` + ` Check that hooks.token in your OpenClaw config matches the token configured for AO.\n` + ` Reconfigure: ao setup openclaw`, ); + shouldRethrowResponseError = true; throw lastError; } lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`); if (!isRetryableHttpStatus(response.status)) { + shouldRethrowResponseError = true; throw lastError; } @@ -163,10 +201,24 @@ async function postWithRetry( ); } } catch (err) { - if (err === lastError) throw err; + if (shouldRethrowResponseError && err === lastError) throw err; lastError = err instanceof Error ? err : new Error(String(err)); - if (lastError.message.includes("ECONNREFUSED")) { + const unreachableCode = getUnreachableNetworkErrorCode(lastError); + if (unreachableCode && (unreachableCode === "ECONNREFUSED" || attempt >= retries)) { + recordActivityEvent({ + sessionId: context.sessionId, + source: "notifier", + kind: "notifier.unreachable", + level: "warn", + summary: `OpenClaw gateway unreachable at ${url}`, + data: { + plugin: "notifier-openclaw", + url, + errorMessage: lastError.message, + fixHint: "openclaw status", + }, + }); throw new Error( `Can't reach OpenClaw gateway at ${url}.\n` + ` Is OpenClaw running? Check: openclaw status\n` + @@ -204,24 +256,170 @@ function eventHeadline(event: OrchestratorEvent): string { warning: "WARNING", info: "INFO", }; - return `[AO ${priorityTag[event.priority]}] ${event.sessionId} ${event.type}`; + return `**AO ${priorityTag[event.priority]}** \`${event.type}\``; } -function stringifyData(data: Record): string { - const entries = Object.entries(data); - if (entries.length === 0) return ""; - return `Context: ${JSON.stringify(data)}`; +function isPrimitive(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function compactValue(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if (isPrimitive(value)) return String(value); + if (value instanceof Date) return value.toISOString(); + return undefined; +} + +function truncate(value: string, maxLength = 140): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; +} + +function escapeMarkdownLinkLabel(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .replace(/\*/g, "\\*") + .replace(/_/g, "\\_") + .replace(/~/g, "\\~") + .replace(/`/g, "\\`"); +} + +function escapeMarkdownLinkUrl(value: string): string { + return value.replace(/[()\\\s<>]/g, (char) => { + const code = char.codePointAt(0) ?? 0; + return `%${code.toString(16).toUpperCase().padStart(2, "0")}`; + }); +} + +function formatLink(label: string, url: string): string { + return `[${escapeMarkdownLinkLabel(label)}](${escapeMarkdownLinkUrl(url)})`; +} + +function pushSection(lines: string[], title: string, items: string[]): void { + const filtered = items.filter(Boolean); + if (filtered.length === 0) return; + lines.push("", `**${title}**`, ...filtered); +} + +function formatSubjectLines(data: NotificationDataV3): string[] { + const subject = data.subject; + const lines = [ + `- Project: \`${subject.session.projectId}\``, + `- Session: \`${subject.session.id}\``, + ]; + + if (subject.issue) { + const label = subject.issue.title + ? `${subject.issue.id} - ${subject.issue.title}` + : subject.issue.id; + lines.push(`- Issue: ${label}`); + } + + return lines; +} + +function formatPrLines(data: NotificationDataV3): string[] { + const pr = data.subject.pr; + if (!pr) return []; + + const title = pr.title ? ` - ${pr.title}` : ""; + const lines = [`- PR: ${formatLink(`#${pr.number}${title}`, pr.url)}`]; + if (pr.branch) lines.push(`- Branch: \`${pr.branch}\``); + if (pr.baseBranch) lines.push(`- Base: \`${pr.baseBranch}\``); + if (typeof pr.isDraft === "boolean") lines.push(`- Draft: ${pr.isDraft ? "yes" : "no"}`); + return lines; +} + +function formatStatusLines(data: NotificationDataV3): string[] { + const lines: string[] = []; + if (data.transition) { + lines.push(`- Transition: \`${data.transition.from}\` -> \`${data.transition.to}\``); + } + if (data.ci?.status) lines.push(`- CI: \`${data.ci.status}\``); + if (data.review?.decision) lines.push(`- Review: \`${data.review.decision}\``); + if (typeof data.review?.unresolvedThreads === "number") { + lines.push(`- Unresolved threads: ${data.review.unresolvedThreads}`); + } + if (typeof data.merge?.ready === "boolean") { + lines.push(`- Merge ready: ${data.merge.ready ? "yes" : "no"}`); + } + if (typeof data.merge?.conflicts === "boolean") { + lines.push(`- Conflicts: ${data.merge.conflicts ? "yes" : "no"}`); + } + if (typeof data.merge?.isBehind === "boolean") { + lines.push(`- Behind base: ${data.merge.isBehind ? "yes" : "no"}`); + } + if (data.reaction) { + lines.push(`- Reaction: \`${data.reaction.key}\` -> \`${data.reaction.action}\``); + } + if (data.escalation) { + lines.push(`- Escalation: ${data.escalation.attempts} attempts (${data.escalation.cause})`); + } + return lines; +} + +function formatCheckLine(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const name = check.url ? formatLink(check.name, check.url) : check.name; + return `- ${name}: \`${status}\``; +} + +function formatCheckLines(data: NotificationDataV3): string[] { + const checks = data.ci?.failedChecks ?? []; + return checks.slice(0, 8).map(formatCheckLine); +} + +function formatBlockerLines(data: NotificationDataV3): string[] { + const blockers = data.merge?.blockers ?? []; + return blockers.slice(0, 8).map((blocker) => `- ${blocker}`); +} + +function formatLinkLines(data: NotificationDataV3): string[] { + const links: string[] = []; + if (data.subject.pr?.url) links.push(`- ${formatLink("Pull request", data.subject.pr.url)}`); + if (data.review?.url) links.push(`- ${formatLink("Review", data.review.url)}`); + return links; +} + +function formatLegacyContext(data: Record): string[] { + return Object.entries(data) + .filter(([, value]) => compactValue(value) !== undefined) + .slice(0, 8) + .map(([key, value]) => `- ${key}: ${truncate(compactValue(value) ?? "")}`); } function formatEscalationMessage(event: OrchestratorEvent): string { - const parts = [eventHeadline(event), event.message, stringifyData(event.data)].filter(Boolean); - return parts.join("\n"); + const lines = [eventHeadline(event), "", event.message]; + const data = getNotificationDataV3(event.data); + + if (!data) { + pushSection(lines, "Session", [ + `- Project: \`${event.projectId}\``, + `- Session: \`${event.sessionId}\``, + ]); + pushSection(lines, "Context", formatLegacyContext(event.data)); + return lines.join("\n"); + } + + pushSection(lines, "Session", formatSubjectLines(data)); + pushSection(lines, "Pull Request", formatPrLines(data)); + pushSection(lines, "Status", formatStatusLines(data)); + pushSection(lines, "Checks", formatCheckLines(data)); + pushSection(lines, "Blockers", formatBlockerLines(data)); + pushSection(lines, "Links", formatLinkLines(data)); + return lines.join("\n"); } function formatActionsLine(actions: NotifyAction[]): string { if (actions.length === 0) return ""; - const labels = actions.map((a) => a.label).join(", "); - return `Actions available: ${labels}`; + const lines = actions.map((action) => { + const target = action.url ?? action.callbackEndpoint; + return target ? `- ${formatLink(action.label, target)}` : `- ${action.label}`; + }); + return ["", "**Actions**", ...lines].join("\n"); } /** @@ -240,10 +438,12 @@ export function create(config?: Record): Notifier { const url = (typeof config?.url === "string" ? config.url : undefined) ?? "http://127.0.0.1:18789/hooks/agent"; + const openclawConfigPath = + typeof config?.openclawConfigPath === "string" ? config.openclawConfigPath : undefined; const token = resolveEnvVarToken(config?.token) ?? - process.env.OPENCLAW_HOOKS_TOKEN ?? - readTokenFromOpenClawConfig(); + readTokenFromOpenClawConfig(openclawConfigPath) ?? + process.env.OPENCLAW_HOOKS_TOKEN; const senderName = typeof config?.name === "string" ? config.name : "AO"; const sessionKeyPrefix = typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:"; @@ -258,7 +458,7 @@ export function create(config?: Record): Notifier { if (!token) { console.warn( "[notifier-openclaw] No token configured.\n" + - " Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" + + " Add hooks.token to your OpenClaw config, or set notifiers.openclaw.openclawConfigPath.\n" + " Run: ao setup openclaw", ); } diff --git a/packages/plugins/notifier-slack/src/index.test.ts b/packages/plugins/notifier-slack/src/index.test.ts index 6c3166bfd..e4581ff6a 100644 --- a/packages/plugins/notifier-slack/src/index.test.ts +++ b/packages/plugins/notifier-slack/src/index.test.ts @@ -16,6 +16,14 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} + function mockFetchOk() { return vi.fn().mockResolvedValue({ ok: true, @@ -23,6 +31,14 @@ function mockFetchOk() { }); } +function getSlackAttachment(body: Record): Record { + return body.attachments[0]; +} + +function getSlackBlocks(body: Record): Array> { + return getSlackAttachment(body).blocks; +} + describe("notifier-slack", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -149,11 +165,13 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ priority: "urgent", sessionId: "backend-3" })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const header = body.blocks[0]; + const blocks = getSlackBlocks(body); + const header = blocks[0]; expect(header.type).toBe("header"); expect(header.text.type).toBe("plain_text"); expect(header.text.text).toContain(":rotating_light:"); - expect(header.text.text).toContain("backend-3"); + expect(body.text).toContain("Session Spawned"); + expect(getSlackAttachment(body).color).toBe("#E01E5A"); }); it("uses correct emoji for each priority level", async () => { @@ -173,7 +191,7 @@ describe("notifier-slack", () => { fetchMock.mockClear(); await notifier.notify(makeEvent({ priority })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.blocks[0].text.text).toContain(emoji); + expect(getSlackBlocks(body)[0].text.text).toContain(emoji); } }); @@ -185,11 +203,27 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ message: "CI is green" })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const section = body.blocks[1]; + const section = getSlackBlocks(body)[1]; expect(section.type).toBe("section"); expect(section.text.text).toBe("CI is green"); }); + it("escapes user-controlled Slack mrkdwn characters", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify( + makeEvent({ message: "Fix *bold* _italic_ ~strike~ `code` & > done" }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const section = getSlackBlocks(body)[1]; + expect(section.text.text).toBe( + "Fix *bold* _italic_ ~strike~ `code` & <tag> > done", + ); + }); + it("includes context block with project and priority", async () => { const fetchMock = mockFetchOk(); vi.stubGlobal("fetch", fetchMock); @@ -198,13 +232,40 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ projectId: "frontend", priority: "action" })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const context = body.blocks[2]; - expect(context.type).toBe("context"); - expect(context.elements[0].text).toContain("*Project:* frontend"); - expect(context.elements[0].text).toContain("*Priority:* action"); + const fieldsBlock = getSlackBlocks(body).find((b) => b.type === "section" && b.fields)!; + expect(fieldsBlock).toBeDefined(); + expect(fieldsBlock.fields[0].text).toContain("*Project*"); + expect(fieldsBlock.fields[0].text).toContain("frontend"); + expect(fieldsBlock.fields[2].text).toContain("*Priority*"); + expect(fieldsBlock.fields[2].text).toContain("Action required"); }); - it("includes PR link when prUrl is a string in event data", async () => { + it("includes PR link when subject.pr.url is present in v3 data", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { number: 42, url: "https://github.com/org/repo/pull/42" }, + }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => + b.type === "actions" && (b as any).elements?.[0]?.text?.text?.includes("View PR"), + )!; + expect(actionsBlock).toBeDefined(); + expect(actionsBlock.elements[0].url).toBe("https://github.com/org/repo/pull/42"); + }); + + it("ignores legacy flat prUrl", async () => { const fetchMock = mockFetchOk(); vi.stubGlobal("fetch", fetchMock); @@ -212,45 +273,14 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const prBlock = body.blocks.find( + const prBlock = getSlackBlocks(body).find( (b: Record) => - b.type === "section" && (b as any).text?.text?.includes("View Pull Request"), - ); - expect(prBlock).toBeDefined(); - expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42"); - }); - - it("ignores prUrl when it is not a string", async () => { - const fetchMock = mockFetchOk(); - vi.stubGlobal("fetch", fetchMock); - - const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { prUrl: 12345 } })); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const prBlock = body.blocks.find( - (b: Record) => - b.type === "section" && (b as any).text?.text?.includes("View Pull Request"), + b.type === "actions" && (b as any).elements?.[0]?.text?.text?.includes("View PR"), ); expect(prBlock).toBeUndefined(); }); - it("ignores ciStatus when it is not a string", async () => { - const fetchMock = mockFetchOk(); - vi.stubGlobal("fetch", fetchMock); - - const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { ciStatus: { nested: true } } })); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( - (b: Record) => - b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), - ); - expect(ciBlock).toBeUndefined(); - }); - - it("includes CI status when ciStatus is a string", async () => { + it("ignores legacy flat ciStatus", async () => { const fetchMock = mockFetchOk(); vi.stubGlobal("fetch", fetchMock); @@ -258,10 +288,25 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( + const ciBlock = getSlackBlocks(body).find( (b: Record) => b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), ); + expect(ciBlock).toBeUndefined(); + }); + + it("includes CI status when ci.status is present in v3 data", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: makeV3Data({ ci: { status: "passing" } }) })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciBlock = getSlackBlocks(body).find( + (b: Record) => + b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), + )!; expect(ciBlock).toBeDefined(); expect(ciBlock.elements[0].text).toContain(":white_check_mark:"); }); @@ -271,14 +316,24 @@ describe("notifier-slack", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { ciStatus: "failing" } })); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + ci: { + status: "failing", + failedChecks: [{ name: "typecheck", status: "failed" }], + }, + }), + }), + ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( + const ciBlock = getSlackBlocks(body).find( (b: Record) => b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), - ); + )!; expect(ciBlock.elements[0].text).toContain(":x:"); + expect(ciBlock.elements[0].text).toContain("typecheck"); }); it("ends with a divider block", async () => { @@ -289,7 +344,8 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent()); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const lastBlock = body.blocks[body.blocks.length - 1]; + const blocks = getSlackBlocks(body); + const lastBlock = blocks[blocks.length - 1]; expect(lastBlock.type).toBe("divider"); }); }); @@ -307,7 +363,9 @@ describe("notifier-slack", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => b.type === "actions", + )!; expect(actionsBlock).toBeDefined(); expect(actionsBlock.elements).toHaveLength(2); expect(actionsBlock.elements[0].type).toBe("button"); @@ -325,9 +383,12 @@ describe("notifier-slack", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => b.type === "actions", + )!; expect(actionsBlock.elements[0].action_id).toBe("ao_kill_session_0"); expect(actionsBlock.elements[0].value).toBe("/api/sessions/app-1/kill"); + expect(actionsBlock.elements[0].style).toBe("danger"); }); it("filters out actions with no url or callback", async () => { @@ -342,7 +403,9 @@ describe("notifier-slack", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => b.type === "actions", + )!; expect(actionsBlock.elements).toHaveLength(1); expect(actionsBlock.elements[0].text.text).toBe("Merge"); }); diff --git a/packages/plugins/notifier-slack/src/index.ts b/packages/plugins/notifier-slack/src/index.ts index 4f122dfad..7a70a9ef6 100644 --- a/packages/plugins/notifier-slack/src/index.ts +++ b/packages/plugins/notifier-slack/src/index.ts @@ -1,4 +1,5 @@ import { + getNotificationDataV3, validateUrl, type PluginModule, type Notifier, @@ -6,6 +7,7 @@ import { type NotifyAction, type NotifyContext, type EventPriority, + type NotificationDataV3, CI_STATUS, } from "@aoagents/ao-core"; @@ -16,20 +18,287 @@ export const manifest = { version: "0.1.0", }; -const PRIORITY_EMOJI: Record = { - urgent: ":rotating_light:", - action: ":point_right:", - warning: ":warning:", - info: ":information_source:", +interface SlackTone { + emoji: string; + label: string; + color: string; +} + +interface SlackButton { + type: "button"; + text: { + type: "plain_text"; + text: string; + emoji: true; + }; + url?: string; + action_id?: string; + value?: string; + style?: "primary" | "danger"; +} + +interface SlackAttachment { + color: string; + fallback: string; + blocks: unknown[]; +} + +const SUCCESS_TONE: SlackTone = { + emoji: ":white_check_mark:", + label: "Complete", + color: "#2EB67D", }; -function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknown[] { +const PRIORITY_TONE: Record = { + urgent: { + emoji: ":rotating_light:", + label: "Urgent", + color: "#E01E5A", + }, + action: { + emoji: ":point_right:", + label: "Action required", + color: "#6157D8", + }, + warning: { + emoji: ":warning:", + label: "Warning", + color: "#ECB22E", + }, + info: { + emoji: ":information_source:", + label: "Information", + color: "#36C5F0", + }, +}; + +function escapeSlackText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\*/g, "*") + .replace(/_/g, "_") + .replace(/~/g, "~") + .replace(/`/g, "`"); +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; +} + +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function formatSlackDate(date: Date): string { + const timestamp = Math.floor(date.getTime() / 1000); + return ``; +} + +function toneForEvent(event: OrchestratorEvent): SlackTone { + if (event.type === "merge.ready") { + return { ...SUCCESS_TONE, label: "Ready to merge" }; + } + if (event.type === "summary.all_complete") { + return { ...SUCCESS_TONE, label: "All complete" }; + } + if (event.type === "ci.failing" || event.type === "session.stuck") { + return PRIORITY_TONE.urgent; + } + if (event.type === "review.changes_requested") { + return PRIORITY_TONE.warning; + } + return PRIORITY_TONE[event.priority] ?? PRIORITY_TONE.info; +} + +function eventTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function formatField(label: string, value: string | number | boolean | undefined | null): unknown { + return { + type: "mrkdwn", + text: `*${escapeSlackText(label)}*\n${escapeSlackText( + value === undefined || value === null || value === "" ? "Not available" : String(value), + )}`, + }; +} + +function buildFieldBlocks(event: OrchestratorEvent, data: NotificationDataV3 | null): unknown[] { + const pr = data?.subject.pr; + const issue = data?.subject.issue; + const branch = + pr?.branch && pr.baseBranch + ? `${pr.branch} -> ${pr.baseBranch}` + : (pr?.branch ?? pr?.baseBranch ?? data?.subject.branch); + const fields = [ + formatField("Project", event.projectId), + formatField("Session", event.sessionId), + formatField("Priority", toneForEvent(event).label), + ...(pr + ? [formatField("Pull Request", `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}`)] + : []), + ...(branch ? [formatField("Branch", branch)] : []), + ...(issue + ? [formatField("Issue", `${issue.id}${issue.title ? ` - ${issue.title}` : ""}`)] + : []), + ...(data?.ci?.status ? [formatField("CI", titleCaseStatus(data.ci.status))] : []), + ...(data?.review?.decision + ? [formatField("Review", titleCaseStatus(data.review.decision))] + : []), + ...(typeof data?.merge?.ready === "boolean" + ? [formatField("Merge", data.merge.ready ? "Ready" : "Not ready")] + : []), + ...(typeof data?.merge?.isBehind === "boolean" + ? [formatField("Sync", data.merge.isBehind ? "Behind base" : "Up to date")] + : []), + ].slice(0, 10); + + if (fields.length === 0) return []; + return [{ type: "section", fields }]; +} + +function buildStatusContext(data: NotificationDataV3 | null): unknown[] { + if (!data) return []; + const context: string[] = []; + + if (data.ci?.status) { + const ciEmoji = data.ci.status === CI_STATUS.PASSING ? ":white_check_mark:" : ":x:"; + const failedChecks = data.ci.failedChecks?.map((check) => escapeSlackText(check.name)) ?? []; + const failedText = failedChecks.length > 0 ? ` | Failed: ${failedChecks.join(", ")}` : ""; + context.push(`${ciEmoji} CI: ${escapeSlackText(data.ci.status)}${failedText}`); + } + + if (typeof data.merge?.conflicts === "boolean") { + context.push( + data.merge.conflicts + ? ":x: Merge conflicts detected" + : ":white_check_mark: No merge conflicts", + ); + } + + if (typeof data.review?.unresolvedThreads === "number") { + context.push(`:speech_balloon: Review threads: ${data.review.unresolvedThreads}`); + } + + if (data.merge?.blockers?.length) { + context.push( + `:no_entry: Blockers: ${data.merge.blockers.slice(0, 5).map(escapeSlackText).join(", ")}`, + ); + } + + if (context.length === 0) return []; + return [ + { + type: "context", + elements: [{ type: "mrkdwn", text: context.join(" • ") }], + }, + ]; +} + +function sanitizeActionId(label: string, index: number): string { + const sanitized = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, ""); + return `ao_${sanitized ? `${sanitized}_${index}` : `action_${index}`}`; +} + +function buildButton(label: string, url: string, style?: "primary" | "danger"): SlackButton { + return { + type: "button", + text: { type: "plain_text", text: truncate(label, 75), emoji: true }, + url, + ...(style ? { style } : {}), + }; +} + +function buildActionElements( + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): SlackButton[] { + const elements: SlackButton[] = []; + const seenUrls = new Set(); + const prUrl = data?.subject.pr?.url; + const reviewUrl = data?.review?.url; + + if (prUrl) { + elements.push(buildButton("View PR", prUrl, "primary")); + seenUrls.add(prUrl); + } + if (reviewUrl && !seenUrls.has(reviewUrl)) { + elements.push(buildButton("View Review", reviewUrl)); + seenUrls.add(reviewUrl); + } + + for (const [index, action] of (actions ?? []).entries()) { + if (action.url) { + if (seenUrls.has(action.url)) continue; + elements.push( + buildButton(action.label, action.url, elements.length === 0 ? "primary" : undefined), + ); + seenUrls.add(action.url); + continue; + } + if (!action.callbackEndpoint) continue; + + const label = truncate(action.label, 75); + const lower = label.toLowerCase(); + elements.push({ + type: "button", + text: { type: "plain_text", text: label, emoji: true }, + action_id: sanitizeActionId(label, index), + value: action.callbackEndpoint, + ...(lower.includes("kill") || lower.includes("cancel") ? { style: "danger" } : {}), + }); + } + + return elements.slice(0, 5); +} + +function buildFallbackText(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const tone = toneForEvent(event); + return `${tone.label}: ${eventTitle(event, data)} — ${event.message}`; +} + +function buildAttachment(event: OrchestratorEvent, actions?: NotifyAction[]): SlackAttachment { + const data = getNotificationDataV3(event.data); + const tone = toneForEvent(event); + const title = eventTitle(event, data); + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; const blocks: unknown[] = [ { type: "header", text: { type: "plain_text", - text: `${PRIORITY_EMOJI[event.priority]} ${event.type} — ${event.sessionId}`, + text: truncate(`${tone.emoji} ${title}`, 150), emoji: true, }, }, @@ -37,84 +306,37 @@ function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknow type: "section", text: { type: "mrkdwn", - text: event.message, + text: `${subtitle ? `*${escapeSlackText(subtitle)}*\n` : ""}${escapeSlackText(event.message)}`, }, }, + ...buildFieldBlocks(event, data), + ...buildStatusContext(data), { type: "context", elements: [ { type: "mrkdwn", - text: `*Project:* ${event.projectId} | *Priority:* ${event.priority} | *Time:* `, + text: `Sent by Agent Orchestrator • ${formatSlackDate(event.timestamp)}`, }, ], }, ]; - // Add PR link if available (type-guarded) - const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; - if (prUrl) { + const actionElements = buildActionElements(data, actions); + if (actionElements.length > 0) { blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `:github: <${prUrl}|View Pull Request>`, - }, + type: "actions", + elements: actionElements, }); } - // Add CI status if available (type-guarded) - const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; - if (ciStatus) { - const ciEmoji = ciStatus === CI_STATUS.PASSING ? ":white_check_mark:" : ":x:"; - blocks.push({ - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${ciEmoji} CI: ${ciStatus}`, - }, - ], - }); - } - - // Add action buttons - if (actions && actions.length > 0) { - const elements = actions - .filter((a) => a.url || a.callbackEndpoint) - .map((action) => { - if (action.url) { - return { - type: "button", - text: { type: "plain_text", text: action.label, emoji: true }, - url: action.url, - }; - } - const sanitized = action.label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_|_$/g, ""); - const idx = actions.indexOf(action); - const actionId = sanitized ? `${sanitized}_${idx}` : `action_${idx}`; - return { - type: "button", - text: { type: "plain_text", text: action.label, emoji: true }, - action_id: `ao_${actionId}`, - value: action.callbackEndpoint, - }; - }); - - if (elements.length > 0) { - blocks.push({ - type: "actions", - elements, - }); - } - } - blocks.push({ type: "divider" }); - return blocks; + return { + color: tone.color, + fallback: buildFallbackText(event, data), + blocks, + }; } async function postToWebhook(webhookUrl: string, payload: Record): Promise { @@ -147,9 +369,11 @@ export function create(config?: Record): Notifier { async notify(event: OrchestratorEvent): Promise { if (!webhookUrl) return; + const attachment = buildAttachment(event); const payload: Record = { username, - blocks: buildBlocks(event), + text: attachment.fallback, + attachments: [attachment], }; if (defaultChannel) payload.channel = defaultChannel; @@ -159,9 +383,11 @@ export function create(config?: Record): Notifier { async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { if (!webhookUrl) return; + const attachment = buildAttachment(event, actions); const payload: Record = { username, - blocks: buildBlocks(event, actions), + text: attachment.fallback, + attachments: [attachment], }; if (defaultChannel) payload.channel = defaultChannel; diff --git a/packages/plugins/scm-github/src/graphql-batch.ts b/packages/plugins/scm-github/src/graphql-batch.ts index 108d47168..cf92add29 100644 --- a/packages/plugins/scm-github/src/graphql-batch.ts +++ b/packages/plugins/scm-github/src/graphql-batch.ts @@ -9,6 +9,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { execGhObserved, + recordActivityEvent, type BatchObserver, type CICheck, type CIStatus, @@ -56,10 +57,10 @@ export function setExecGhAsync( * Configuration constants for cache sizing. * LRU cache automatically evicts oldest entries when these limits are reached. */ -const MAX_PR_LIST_ETAGS = 100; // Number of repos to cache -const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache -const MAX_REVIEW_COMMENTS_ETAGS = 500; // Number of PRs to cache review ETags -const MAX_PR_METADATA = 200; // Number of PRs to cache full data +const MAX_PR_LIST_ETAGS = 100; // Number of repos to cache +const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache +const MAX_REVIEW_COMMENTS_ETAGS = 500; // Number of PRs to cache review ETags +const MAX_PR_METADATA = 200; // Number of PRs to cache full data /** * ETag cache for REST API endpoints. @@ -125,11 +126,7 @@ export function getPRListETag(owner: string, repo: string): string | undefined { /** * Get commit status ETag for a specific commit. */ -export function getCommitStatusETag( - owner: string, - repo: string, - sha: string, -): string | undefined { +export function getCommitStatusETag(owner: string, repo: string, sha: string): string | undefined { return etagCache.commitStatus.get(`${owner}/${repo}#${sha}`); } @@ -145,12 +142,7 @@ export function setPRListETag(owner: string, repo: string, etag: string): void { * Set commit status ETag for a specific commit. * Exported for testing. */ -export function setCommitStatusETag( - owner: string, - repo: string, - sha: string, - etag: string, -): void { +export function setCommitStatusETag(owner: string, repo: string, sha: string, etag: string): void { etagCache.commitStatus.set(`${owner}/${repo}#${sha}`, etag); } @@ -161,10 +153,9 @@ export function setCommitStatusETag( * * Uses LRU eviction to ensure bounded memory usage. */ -const prMetadataCache = new LRUCache< - string, - { headSha: string | null; ciStatus: CIStatus } ->(MAX_PR_METADATA); +const prMetadataCache = new LRUCache( + MAX_PR_METADATA, +); /** * Cache for full PR enrichment data. @@ -241,7 +232,11 @@ export async function shouldRefreshPREnrichment( } if (repos.size === 0) { - return { shouldRefresh: false, details: ["No repos to check"], prListUnchangedRepos: new Set() }; + return { + shouldRefresh: false, + details: ["No repos to check"], + prListUnchangedRepos: new Set(), + }; } // Guard 1: Check PR list ETag for each repository @@ -297,9 +292,7 @@ export async function shouldRefreshPREnrichment( ); if (statusChanged) { shouldRefresh = true; - details.push( - `CI status changed for ${pr.owner}/${pr.repo}#${pr.number} (Guard 2)`, - ); + details.push(`CI status changed for ${pr.owner}/${pr.repo}#${pr.number} (Guard 2)`); } } } @@ -310,10 +303,7 @@ export async function shouldRefreshPREnrichment( /** * Get cached PR metadata for testing. */ -export function getPRMetadataCache(): Map< - string, - { headSha: string | null; ciStatus: CIStatus } -> { +export function getPRMetadataCache(): Map { return prMetadataCache.toMap(); } @@ -350,6 +340,11 @@ interface ErrorWithCause extends Error { cause?: unknown; } +// Module-level guard so we only emit gh_unavailable once per process. +// The error is system-wide (gh missing globally), not session-specific. +let ghUnavailableEmitted = false; +const batchEnrichPRFailedEmitted = new Set(); + /** * Pre-flight check to verify gh CLI is available and authenticated. * This prevents silent failures during GraphQL batch queries. @@ -357,7 +352,21 @@ interface ErrorWithCause extends Error { async function verifyGhCLI(): Promise { try { await execFileAsync("gh", ["--version"], { timeout: 5000 }); - } catch { + } catch (err) { + if (!ghUnavailableEmitted) { + ghUnavailableEmitted = true; + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.gh_unavailable", + level: "error", + summary: "gh CLI not available or not authenticated", + data: { + plugin: "scm-github", + errorMessage, + }, + }); + } const error = new Error( "gh CLI not available or not authenticated. GraphQL batch enrichment requires gh CLI to be installed and configured.", ) as ErrorWithCause; @@ -366,6 +375,16 @@ async function verifyGhCLI(): Promise { } } +/** Test-only: reset the once-per-process gh_unavailable guard. */ +export function _resetGhUnavailableEmittedForTesting(): void { + ghUnavailableEmitted = false; +} + +/** Test-only: reset the once-per-PR batch extraction failure guard. */ +export function _resetBatchEnrichPRFailedEmittedForTesting(): void { + batchEnrichPRFailedEmitted.clear(); +} + /** * Maximum number of PRs to query in a single GraphQL batch. * GitHub has limits on query complexity and we stay well under this limit. @@ -533,7 +552,10 @@ async function checkCommitStatusETag( if (is304(errorMsg)) { return false; } - observer?.log("warn", `[ETag Guard 2] Commit status check failed for ${commitKey}: ${errorMsg}`); + observer?.log( + "warn", + `[ETag Guard 2] Commit status check failed for ${commitKey}: ${errorMsg}`, + ); return true; // Assume changed to be safe } } @@ -596,7 +618,10 @@ export async function checkReviewCommentsETag( if (is304(errorMsg)) { return false; } - observer?.log("warn", `[ETag Guard 3] Review comments check failed for ${cacheKey}: ${errorMsg}`); + observer?.log( + "warn", + `[ETag Guard 3] Review comments check failed for ${cacheKey}: ${errorMsg}`, + ); return true; // Assume changed to be safe } } @@ -704,9 +729,7 @@ export function generateBatchQuery(prs: PRInfo[]): { * * @throws Error if the query fails with GraphQL errors or parsing issues. */ -async function executeBatchQuery( - prs: PRInfo[], -): Promise> { +async function executeBatchQuery(prs: PRInfo[]): Promise> { const { query, variables } = generateBatchQuery(prs); // Handle empty array - no query needed @@ -866,9 +889,7 @@ function parseCheckContexts(contexts: unknown): CICheck[] { * Uses only the top-level aggregate state to determine overall CI status. * Individual check details are parsed separately via parseCheckContexts(). */ -function parseCIState( - statusCheckRollup: unknown, -): CIStatus { +function parseCIState(statusCheckRollup: unknown): CIStatus { if (!statusCheckRollup || typeof statusCheckRollup !== "object") { return "none"; } @@ -885,8 +906,7 @@ function parseCIState( if (state === "PENDING" || state === "EXPECTED") return "pending"; if (state === "TIMED_OUT" || state === "CANCELLED" || state === "ACTION_REQUIRED") return "failing"; - if (state === "QUEUED" || state === "IN_PROGRESS" || state === "WAITING") - return "pending"; + if (state === "QUEUED" || state === "IN_PROGRESS" || state === "WAITING") return "pending"; return "none"; } @@ -927,11 +947,7 @@ function extractPREnrichment( const pr = pullRequest as Record; // Check for at least one required field to validate this is a valid PR object - if ( - pr["state"] === undefined && - pr["title"] === undefined && - pr["commits"] === undefined - ) { + if (pr["state"] === undefined && pr["title"] === undefined && pr["commits"] === undefined) { return null; } @@ -954,9 +970,7 @@ function extractPREnrichment( // Extract merge info const mergeable = pr["mergeable"]; const mergeStateStatus = - typeof pr["mergeStateStatus"] === "string" - ? pr["mergeStateStatus"].toUpperCase() - : ""; + typeof pr["mergeStateStatus"] === "string" ? pr["mergeStateStatus"].toUpperCase() : ""; const hasConflicts = mergeable === "CONFLICTING"; const isBehind = mergeStateStatus === "BEHIND"; @@ -974,9 +988,7 @@ function extractPREnrichment( // contexts(first: 20) silently truncates PRs with >20 checks — when truncated, // the failing check may be missing, so we set ciChecks to undefined to force // the getCIChecks() REST fallback in maybeDispatchCIFailureDetails. - const contextsField = statusCheckRollup?.["contexts"] as - | Record - | undefined; + const contextsField = statusCheckRollup?.["contexts"] as Record | undefined; const pageInfo = contextsField?.["pageInfo"]; const contextsHasNextPage = pageInfo !== null && @@ -984,15 +996,12 @@ function extractPREnrichment( typeof pageInfo === "object" && (pageInfo as Record)["hasNextPage"] === true; const ciChecks = - contextsField && !contextsHasNextPage - ? parseCheckContexts(contextsField) - : undefined; + contextsField && !contextsHasNextPage ? parseCheckContexts(contextsField) : undefined; // Build blockers list const blockers: string[] = []; if (ciStatus === "failing") blockers.push("CI is failing"); - if (reviewDecision === "changes_requested") - blockers.push("Changes requested in review"); + if (reviewDecision === "changes_requested") blockers.push("Changes requested in review"); if (reviewDecision === "pending") blockers.push("Review required"); if (hasConflicts) blockers.push("Merge conflicts"); if (isBehind) blockers.push("Branch is behind base branch"); @@ -1126,6 +1135,25 @@ export async function enrichSessionsPRBatch( result.set(prKey, enrichment); // Update PR metadata cache for future ETag checks updatePRMetadataCache(prKey, enrichment, headSha); + } else { + // GraphQL returned a PR object but extractPREnrichment couldn't + // parse it (missing fields, schema drift). Distinct from the + // whole-batch failure D02 catches further down. + if (!batchEnrichPRFailedEmitted.has(prKey)) { + batchEnrichPRFailedEmitted.add(prKey); + recordActivityEvent({ + source: "scm", + kind: "scm.batch_enrich_pr_failed", + level: "warn", + summary: `batch enrich extraction failed for PR #${pr.number}`, + data: { + plugin: "scm-github", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + }, + }); + } } } else { // PR not found (deleted/closed/permission issue) @@ -1147,7 +1175,10 @@ export async function enrichSessionsPRBatch( durationMs: batchDuration, }; observer?.recordSuccess(successData); - observer?.log("info", `[GraphQL Batch Success] Batch ${batchIndex + 1}/${batches.length} succeeded: added ${prCountAfter - prCountBefore} PRs to cache (${batchDuration}ms)`); + observer?.log( + "info", + `[GraphQL Batch Success] Batch ${batchIndex + 1}/${batches.length} succeeded: added ${prCountAfter - prCountBefore} PRs to cache (${batchDuration}ms)`, + ); } } catch (err) { // Calculate duration even on failure diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 8d065db01..fa59cecdc 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -11,6 +11,7 @@ import { CI_STATUS, execGhObserved, memoizeAsync, + recordActivityEvent, type PluginModule, type PreflightContext, type SCM, @@ -62,6 +63,12 @@ const BOT_AUTHORS = new Set([ ]); const CI_FAILURE_LOG_TAIL_LINES = 120; +const ciSummaryFailClosedEmitted = new Set(); + +/** Test-only: reset once-per-PR activity event guards. */ +export function _resetGitHubActivityEventDedupeForTesting(): void { + ciSummaryFailClosedEmitted.clear(); +} // --------------------------------------------------------------------------- // Helpers @@ -237,10 +244,7 @@ async function getFailedJobLog( ]); } catch (err) { if (!runReference.jobId) throw err; - return gh([ - "api", - `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`, - ]); + return gh(["api", `repos/${pr.owner}/${pr.repo}/actions/jobs/${runReference.jobId}/logs`]); } } @@ -522,6 +526,10 @@ function repoFlag(pr: PRInfo): string { return `${pr.owner}/${pr.repo}`; } +function prEventKey(pr: PRInfo): string { + return `${repoFlag(pr)}#${pr.number}`; +} + function parseDate(val: string | undefined | null): Date { if (!val) return new Date(0); const d = new Date(val); @@ -932,7 +940,7 @@ function createGitHubSCM(): SCM { let checks: CICheck[]; try { checks = await this.getCIChecks(pr); - } catch { + } catch (err) { // Before fail-closing, check if the PR is merged/closed — // GitHub may not return check data for those, and reporting // "failing" for a merged PR is wrong. @@ -943,7 +951,26 @@ function createGitHubSCM(): SCM { // Can't determine state either; fall through to fail-closed. } // Fail closed for open PRs: report as failing rather than - // "none" (which getMergeability treats as passing). + // "none" (which getMergeability treats as passing). Emit so RCA + // can distinguish "really failing" from "we couldn't tell". + const eventKey = prEventKey(pr); + if (!ciSummaryFailClosedEmitted.has(eventKey)) { + ciSummaryFailClosedEmitted.add(eventKey); + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + summary: `getCISummary failed-closed for PR #${pr.number}`, + data: { + plugin: "scm-github", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + errorMessage, + }, + }); + } return "failing"; } if (checks.length === 0) return "none"; @@ -1035,16 +1062,16 @@ function createGitHubSCM(): SCM { try { // Use GraphQL with variables to get review threads with actual isResolved status const raw = await gh([ - "api", - "graphql", - "-f", - `owner=${pr.owner}`, - "-f", - `name=${pr.repo}`, - "-F", - `number=${pr.number}`, - "-f", - `query=query($owner: String!, $name: String!, $number: Int!) { + "api", + "graphql", + "-f", + `owner=${pr.owner}`, + "-f", + `name=${pr.repo}`, + "-F", + `number=${pr.number}`, + "-f", + `query=query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100) { @@ -1067,58 +1094,58 @@ function createGitHubSCM(): SCM { } } }`, - ]); + ]); - const data: { - data: { - repository: { - pullRequest: { - reviewThreads: { - nodes: Array<{ - id: string; - isResolved: boolean; - comments: { - nodes: Array<{ - id: string; - author: { login: string } | null; - body: string; - path: string | null; - line: number | null; - url: string; - createdAt: string; - }>; - }; - }>; + const data: { + data: { + repository: { + pullRequest: { + reviewThreads: { + nodes: Array<{ + id: string; + isResolved: boolean; + comments: { + nodes: Array<{ + id: string; + author: { login: string } | null; + body: string; + path: string | null; + line: number | null; + url: string; + createdAt: string; + }>; + }; + }>; + }; }; }; }; - }; - } = JSON.parse(raw); + } = JSON.parse(raw); - const threads = data.data.repository.pullRequest.reviewThreads.nodes; + const threads = data.data.repository.pullRequest.reviewThreads.nodes; - return threads - .filter((t) => { - if (t.isResolved) return false; // only pending (unresolved) threads - const c = t.comments.nodes[0]; - if (!c) return false; // skip threads with no comments - const author = c.author?.login ?? ""; - return !BOT_AUTHORS.has(author); - }) - .map((t) => { - const c = t.comments.nodes[0]; - return { - id: c.id, - threadId: t.id, - author: c.author?.login ?? "unknown", - body: c.body, - path: c.path || undefined, - line: c.line ?? undefined, - isResolved: t.isResolved, - createdAt: parseDate(c.createdAt), - url: c.url, - }; - }); + return threads + .filter((t) => { + if (t.isResolved) return false; // only pending (unresolved) threads + const c = t.comments.nodes[0]; + if (!c) return false; // skip threads with no comments + const author = c.author?.login ?? ""; + return !BOT_AUTHORS.has(author); + }) + .map((t) => { + const c = t.comments.nodes[0]; + return { + id: c.id, + threadId: t.id, + author: c.author?.login ?? "unknown", + body: c.body, + path: c.path || undefined, + line: c.line ?? undefined, + isResolved: t.isResolved, + createdAt: parseDate(c.createdAt), + url: c.url, + }; + }); } catch (err) { throw new Error("Failed to fetch pending comments", { cause: err }); } @@ -1129,7 +1156,12 @@ function createGitHubSCM(): SCM { const cacheKey = `${pr.owner}/${pr.repo}#${pr.number}`; // Guard 3: check if review comments changed via REST ETag - const reviewsChanged = await checkReviewCommentsETag(pr.owner, pr.repo, pr.number, instanceObserver); + const reviewsChanged = await checkReviewCommentsETag( + pr.owner, + pr.repo, + pr.number, + instanceObserver, + ); if (!reviewsChanged) { const cached = reviewThreadsCache.get(cacheKey); if (cached) return cached; diff --git a/packages/plugins/scm-github/test/activity-events.test.ts b/packages/plugins/scm-github/test/activity-events.test.ts new file mode 100644 index 000000000..ad54976df --- /dev/null +++ b/packages/plugins/scm-github/test/activity-events.test.ts @@ -0,0 +1,152 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers scm.gh_unavailable (MUST emit, deduped once-per-process). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { + enrichSessionsPRBatch, + setExecFileAsync, + setExecGhAsync, + clearETagCache, + clearPRMetadataCache, + _resetGhUnavailableEmittedForTesting, + _resetBatchEnrichPRFailedEmittedForTesting, +} from "../src/graphql-batch.js"; +import type { PRInfo } from "@aoagents/ao-core"; + +const samplePRs: PRInfo[] = [ + { + owner: "octocat", + repo: "hello-world", + number: 42, + url: "https://github.com/octocat/hello-world/pull/42", + title: "Add new feature", + branch: "feature/new", + baseBranch: "main", + isDraft: false, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + clearETagCache(); + clearPRMetadataCache(); + _resetGhUnavailableEmittedForTesting(); + _resetBatchEnrichPRFailedEmittedForTesting(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("scm.gh_unavailable (MUST emit)", () => { + it("emits when verifyGhCLI fails because gh is missing/unauthenticated", async () => { + const execFileMock = vi.fn().mockImplementation((file: string) => { + if (file === "gh") { + const err = new Error("spawn gh ENOENT") as Error & { code?: string }; + err.code = "ENOENT"; + return Promise.reject(err); + } + return Promise.resolve({ stdout: "", stderr: "" }); + }); + setExecFileAsync(execFileMock as unknown as Parameters[0]); + + // The batch-level try/catch swallows verifyGhCLI's throw — but the event + // fires before the throw, which is what we care about for RCA. + const result = await enrichSessionsPRBatch(samplePRs); + expect(result.enrichment.size).toBe(0); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "scm", + kind: "scm.gh_unavailable", + level: "error", + data: expect.objectContaining({ + plugin: "scm-github", + errorMessage: expect.any(String), + }), + }), + ); + }); + + it("emits exactly once across multiple gh-missing failures (deduped per-process)", async () => { + const execFileMock = vi.fn().mockImplementation((file: string) => { + if (file === "gh") { + return Promise.reject(new Error("gh ENOENT")); + } + return Promise.resolve({ stdout: "", stderr: "" }); + }); + setExecFileAsync(execFileMock as unknown as Parameters[0]); + + await enrichSessionsPRBatch(samplePRs); + await enrichSessionsPRBatch(samplePRs); + await enrichSessionsPRBatch(samplePRs); + + const ghUnavailableCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.gh_unavailable", + ); + expect(ghUnavailableCalls).toHaveLength(1); + }); +}); + +describe("scm.batch_enrich_pr_failed (poll-path emit)", () => { + it("emits exactly once per PR across repeated extraction failures", async () => { + const execFileMock = vi.fn().mockResolvedValue({ stdout: "gh version", stderr: "" }); + setExecFileAsync(execFileMock as unknown as Parameters[0]); + + const execGhMock = vi.fn( + async (_args: string[], _timeout: number, operation: string): Promise => { + if (operation === "gh.api.guard-pr-list") { + return 'HTTP/2 200 OK\netag: W/"pr-list"\n\n[]'; + } + if (operation === "gh.api.graphql-batch") { + return `HTTP/2 200 OK\n\n${JSON.stringify({ + data: { + pr0: { + pullRequest: { unexpectedShape: true }, + }, + }, + })}`; + } + throw new Error(`Unexpected gh operation: ${operation}`); + }, + ); + setExecGhAsync(execGhMock); + + await enrichSessionsPRBatch(samplePRs); + await enrichSessionsPRBatch(samplePRs); + + const extractionFailureCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.batch_enrich_pr_failed", + ); + expect(extractionFailureCalls).toHaveLength(1); + expect(extractionFailureCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.batch_enrich_pr_failed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-github", + prNumber: 42, + prOwner: "octocat", + prRepo: "hello-world", + }), + }), + ); + }); +}); diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 501ff4e09..7fee69578 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -4,7 +4,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // Mock node:child_process — gh CLI calls go through execFileAsync = promisify(execFile) // vi.hoisted ensures the mock fn is available when vi.mock factory runs (hoisted above imports) // --------------------------------------------------------------------------- -const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() })); +const { ghMock, recordActivityEventMock } = vi.hoisted(() => ({ + ghMock: vi.fn(), + recordActivityEventMock: vi.fn(), +})); vi.mock("node:child_process", () => { // Attach the custom promisify symbol so `promisify(execFile)` returns ghMock @@ -14,8 +17,24 @@ vi.mock("node:child_process", () => { return { execFile }; }); -import { create, manifest } from "../src/index.js"; -import { _clearProcessCacheForTests, createActivitySignal, type PreflightContext, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core"; +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { create, manifest, _resetGitHubActivityEventDedupeForTesting } from "../src/index.js"; +import { + _clearProcessCacheForTests, + createActivitySignal, + type PreflightContext, + type PRInfo, + type SCMWebhookRequest, + type Session, + type ProjectConfig, +} from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -101,6 +120,7 @@ describe("scm-github plugin", () => { beforeEach(() => { vi.clearAllMocks(); + _resetGitHubActivityEventDedupeForTesting(); scm = create(); delete process.env["GITHUB_WEBHOOK_SECRET"]; }); @@ -665,13 +685,10 @@ describe("scm-github plugin", () => { url: "https://github.com/acme/repo/actions/runs/124/job/457", }, ]; - const logLines = Array.from( - { length: 125 }, - (_, index) => { - const step = index < 100 ? "Install dependencies" : "Run pnpm test"; - return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`; - }, - ); + const logLines = Array.from({ length: 125 }, (_, index) => { + const step = index < 100 ? "Install dependencies" : "Run pnpm test"; + return `build\t${step}\t2026-05-12T00:00:00Z line ${index + 1}`; + }); mockGhRaw(logLines.join("\n")); const summary = await scm.getCIFailureSummary?.(pr, checks); @@ -774,6 +791,34 @@ describe("scm-github plugin", () => { expect(await scm.getCISummary(pr)).toBe("failing"); }); + it("dedupes fail-closed activity events per PR", async () => { + mockGhError("checks failed"); + mockGhError("state failed"); + mockGhError("checks failed again"); + mockGhError("state failed again"); + + expect(await scm.getCISummary(pr)).toBe("failing"); + expect(await scm.getCISummary(pr)).toBe("failing"); + + const failClosedCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.ci_summary_failclosed", + ); + expect(failClosedCalls).toHaveLength(1); + expect(failClosedCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-github", + prNumber: 42, + prOwner: "acme", + prRepo: "repo", + }), + }), + ); + }); + it('returns "none" when all checks are skipped', async () => { mockGh([ { name: "a", state: "SKIPPED" }, diff --git a/packages/plugins/scm-gitlab/src/index.ts b/packages/plugins/scm-gitlab/src/index.ts index df9511244..31402653a 100644 --- a/packages/plugins/scm-gitlab/src/index.ts +++ b/packages/plugins/scm-gitlab/src/index.ts @@ -7,6 +7,7 @@ import { createHash, timingSafeEqual } from "node:crypto"; import { CI_STATUS, + recordActivityEvent, type PluginModule, type SCM, type SCMWebhookEvent, @@ -45,6 +46,14 @@ const BOT_AUTHORS = new Set([ "sonarcloud[bot]", "snyk-bot", ]); +const ciSummaryFailClosedEmitted = new Set(); +const reviewFetchFailedEmitted = new Set(); + +/** Test-only: reset once-per-PR activity event guards. */ +export function _resetGitLabActivityEventDedupeForTesting(): void { + ciSummaryFailClosedEmitted.clear(); + reviewFetchFailedEmitted.clear(); +} function isBot(username: string): boolean { return ( @@ -60,6 +69,10 @@ function repoFlag(pr: PRInfo): string { return `${pr.owner}/${pr.repo}`; } +function prEventKey(pr: PRInfo): string { + return `${repoFlag(pr)}#${pr.number}`; +} + function parseDate(val: string | undefined | null): Date { if (!val) return new Date(0); const d = new Date(val); @@ -106,7 +119,6 @@ function mapPRState(state: string): PRState { return "open"; } - function getGitLabWebhookConfig(project: ProjectConfig) { const webhook = project.scm?.webhook; return { @@ -585,6 +597,24 @@ function createGitLabSCM(config?: Record): SCM { `getCISummary: PR state fallback also failed for MR !${pr.number}: ${(innerErr as Error).message}`, ); } + const eventKey = prEventKey(pr); + if (!ciSummaryFailClosedEmitted.has(eventKey)) { + ciSummaryFailClosedEmitted.add(eventKey); + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + summary: `getCISummary failed-closed for MR !${pr.number}`, + data: { + plugin: "scm-gitlab", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + errorMessage, + }, + }); + } return "failing"; } if (checks.length === 0) return "none"; @@ -642,6 +672,24 @@ function createGitLabSCM(config?: Record): SCM { console.warn( `getReviews: discussions fetch failed for MR !${pr.number}: ${(err as Error).message}`, ); + const eventKey = prEventKey(pr); + if (!reviewFetchFailedEmitted.has(eventKey)) { + reviewFetchFailedEmitted.add(eventKey); + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "scm", + kind: "scm.review_fetch_failed", + level: "warn", + summary: `getReviews discussions fetch failed for MR !${pr.number}`, + data: { + plugin: "scm-gitlab", + prNumber: pr.number, + prOwner: pr.owner, + prRepo: pr.repo, + errorMessage, + }, + }); + } } return reviews; diff --git a/packages/plugins/scm-gitlab/test/index.test.ts b/packages/plugins/scm-gitlab/test/index.test.ts index 9dad4d0eb..c72d93ee0 100644 --- a/packages/plugins/scm-gitlab/test/index.test.ts +++ b/packages/plugins/scm-gitlab/test/index.test.ts @@ -3,7 +3,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // --------------------------------------------------------------------------- // Mock node:child_process — glab CLI calls go through execFileAsync // --------------------------------------------------------------------------- -const { glabMock } = vi.hoisted(() => ({ glabMock: vi.fn() })); +const { glabMock, recordActivityEventMock } = vi.hoisted(() => ({ + glabMock: vi.fn(), + recordActivityEventMock: vi.fn(), +})); vi.mock("node:child_process", () => { const execFile = Object.assign(vi.fn(), { @@ -12,8 +15,22 @@ vi.mock("node:child_process", () => { return { execFile }; }); -import { create, manifest } from "../src/index.js"; -import { createActivitySignal, type PRInfo, type Session, type ProjectConfig, type SCMWebhookRequest } from "@aoagents/ao-core"; +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +import { create, manifest, _resetGitLabActivityEventDedupeForTesting } from "../src/index.js"; +import { + createActivitySignal, + type PRInfo, + type Session, + type ProjectConfig, + type SCMWebhookRequest, +} from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Fixtures @@ -104,6 +121,7 @@ describe("scm-gitlab plugin", () => { beforeEach(() => { vi.clearAllMocks(); + _resetGitLabActivityEventDedupeForTesting(); warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); scm = create(); delete process.env["GITLAB_WEBHOOK_SECRET"]; @@ -701,6 +719,34 @@ describe("scm-gitlab plugin", () => { expect(await scm.getCISummary(pr)).toBe("failing"); }); + it("dedupes fail-closed activity events per MR", async () => { + mockGlabError("pipeline error"); + mockGlabError("network error"); + mockGlabError("pipeline error again"); + mockGlabError("network error again"); + + expect(await scm.getCISummary(pr)).toBe("failing"); + expect(await scm.getCISummary(pr)).toBe("failing"); + + const failClosedCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.ci_summary_failclosed", + ); + expect(failClosedCalls).toHaveLength(1); + expect(failClosedCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.ci_summary_failclosed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-gitlab", + prNumber: 42, + prOwner: "acme", + prRepo: "repo", + }), + }), + ); + }); + it('returns "none" when all jobs are skipped', async () => { mockGlab([{ id: 1 }]); mockGlab([ @@ -807,6 +853,34 @@ describe("scm-gitlab plugin", () => { ); }); + it("dedupes review fetch failed activity events per MR", async () => { + mockGlab({ approved_by: [] }); + mockGlabError("discussions fetch failed"); + mockGlab({ approved_by: [] }); + mockGlabError("discussions fetch failed again"); + + expect(await scm.getReviews(pr)).toEqual([]); + expect(await scm.getReviews(pr)).toEqual([]); + + const reviewFetchCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "scm.review_fetch_failed", + ); + expect(reviewFetchCalls).toHaveLength(1); + expect(reviewFetchCalls[0]?.[0]).toEqual( + expect.objectContaining({ + source: "scm", + kind: "scm.review_fetch_failed", + level: "warn", + data: expect.objectContaining({ + plugin: "scm-gitlab", + prNumber: 42, + prOwner: "acme", + prRepo: "repo", + }), + }), + ); + }); + it("filters bot authors from discussions", async () => { mockGlab({ approved_by: [] }); mockGlab([ diff --git a/packages/plugins/tracker-linear/src/activity-events.test.ts b/packages/plugins/tracker-linear/src/activity-events.test.ts new file mode 100644 index 000000000..06d552ee4 --- /dev/null +++ b/packages/plugins/tracker-linear/src/activity-events.test.ts @@ -0,0 +1,143 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers tracker.dep_missing (MUST emit, deduped once-per-process). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { recordActivityEventMock, requestMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), + requestMock: vi.fn(), +})); + +vi.mock("node:https", () => ({ + request: requestMock, +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +// @composio/core is intentionally not installed — the real dynamic import +// will fail with ERR_MODULE_NOT_FOUND, which is exactly the dep_missing +// shape we want to exercise. + +import { create, _resetDepMissingEmittedForTesting } from "./index.js"; +import type { ProjectConfig } from "@aoagents/ao-core"; + +beforeEach(() => { + vi.clearAllMocks(); + recordActivityEventMock.mockReset(); + requestMock.mockReset(); + _resetDepMissingEmittedForTesting(); + process.env.COMPOSIO_API_KEY = "test-key"; + process.env.COMPOSIO_ENTITY_ID = "test-entity"; + delete process.env.LINEAR_API_KEY; +}); + +afterEach(() => { + delete process.env.COMPOSIO_API_KEY; + delete process.env.COMPOSIO_ENTITY_ID; + delete process.env.LINEAR_API_KEY; + vi.useRealTimers(); +}); + +function makeProject(): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { teamId: "TEAM-1" }, + }; +} + +describe("tracker.dep_missing (MUST emit)", () => { + it("emits when Composio SDK is not installed", async () => { + const tracker = create(); + + // Any tracker call routes through the composio transport, which will + // fail to load the missing SDK on first use. + await expect(tracker.getIssue("TEST-1", makeProject())).rejects.toThrow( + /Composio SDK.*not installed/, + ); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "tracker", + kind: "tracker.dep_missing", + level: "error", + data: expect.objectContaining({ + plugin: "tracker-linear", + package: "@composio/core", + }), + }), + ); + }); + + it("emits exactly once across multiple calls (deduped per-process)", async () => { + const tracker = create(); + + await expect(tracker.getIssue("TEST-1", makeProject())).rejects.toThrow(); + await expect(tracker.getIssue("TEST-2", makeProject())).rejects.toThrow(); + await expect(tracker.getIssue("TEST-3", makeProject())).rejects.toThrow(); + + const depMissingCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "tracker.dep_missing", + ); + expect(depMissingCalls).toHaveLength(1); + }); +}); + +describe("tracker.api_timeout", () => { + it("rejects direct transport timeouts even when activity logging throws", async () => { + delete process.env.COMPOSIO_API_KEY; + delete process.env.COMPOSIO_ENTITY_ID; + process.env.LINEAR_API_KEY = "linear-key"; + vi.useFakeTimers(); + + const req = { + setTimeout: vi.fn(), + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }; + req.setTimeout.mockImplementation((_timeoutMs: number, cb: () => void) => { + setTimeout(cb, 0); + return req; + }); + req.on.mockReturnValue(req); + requestMock.mockReturnValue(req); + recordActivityEventMock.mockImplementationOnce(() => { + throw new Error("activity sink failed"); + }); + + const tracker = create(); + const timeoutExpectation = expect(tracker.getIssue("TEST-1", makeProject())).rejects.toThrow( + "Linear API request timed out after 30s", + ); + + await vi.runAllTimersAsync(); + await timeoutExpectation; + expect(req.destroy).toHaveBeenCalled(); + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + data: expect.objectContaining({ + plugin: "tracker-linear", + transport: "direct", + timeoutMs: 30_000, + }), + }), + ); + }); +}); diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index fd93e3eb8..850431e30 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -9,17 +9,35 @@ */ import { request } from "node:https"; -import type { - PluginModule, - Tracker, - Issue, - IssueFilters, - IssueUpdate, - CreateIssueInput, - ProjectConfig, +import { + recordActivityEvent, + type CreateIssueInput, + type Issue, + type IssueFilters, + type IssueUpdate, + type PluginModule, + type ProjectConfig, + type Tracker, } from "@aoagents/ao-core"; import type { Composio } from "@composio/core"; +// Module-level guard so we only emit tracker.dep_missing once per process +// even if multiple sessions trigger the missing-SDK path. +let depMissingEmitted = false; + +/** Test-only: reset the once-per-process dep_missing guard. */ +export function _resetDepMissingEmittedForTesting(): void { + depMissingEmitted = false; +} + +function recordTransportActivityEvent(event: Parameters[0]): void { + try { + recordActivityEvent(event); + } catch { + // Activity logging must never prevent timeout promises from settling. + } +} + // --------------------------------------------------------------------------- // Transport abstraction // --------------------------------------------------------------------------- @@ -35,6 +53,43 @@ type GraphQLTransport = (query: string, variables?: Record) // --------------------------------------------------------------------------- const LINEAR_API_URL = "https://api.linear.app/graphql"; +const DIRECT_TRANSPORT_MAX_ATTEMPTS = 3; +const DIRECT_TRANSPORT_RETRY_DELAY_MS = 500; + +class LinearHttpError extends Error { + constructor( + readonly status: number, + body: string, + ) { + super(`Linear API returned HTTP ${status}: ${body.slice(0, 200)}`); + } + + get transient(): boolean { + return this.status === 408 || this.status === 429 || this.status >= 500; + } +} + +class LinearNetworkError extends Error { + constructor(message: string) { + super(`Linear API network error: ${message}`); + } +} + +class LinearTimeoutError extends Error { + constructor() { + super("Linear API request timed out after 30s"); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableDirectTransportError(err: unknown): boolean { + if (err instanceof LinearHttpError) return err.transient; + if (err instanceof LinearTimeoutError) return true; + return err instanceof LinearNetworkError; +} function getApiKey(): string { const key = process.env["LINEAR_API_KEY"]; @@ -52,73 +107,100 @@ interface LinearResponse { } function createDirectTransport(): GraphQLTransport { - return (query: string, variables?: Record): Promise => { + return async (query: string, variables?: Record): Promise => { const apiKey = getApiKey(); const body = JSON.stringify({ query, variables }); - return new Promise((resolve, reject) => { - const url = new URL(LINEAR_API_URL); - let settled = false; - const settle = (fn: () => void) => { - if (!settled) { - settled = true; - fn(); - } - }; + const execute = (): Promise => + new Promise((resolve, reject) => { + const url = new URL(LINEAR_API_URL); + let settled = false; + const settle = (fn: () => void) => { + if (!settled) { + settled = true; + fn(); + } + }; - const req = request( - { - hostname: url.hostname, - path: url.pathname, - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: apiKey, - "Content-Length": Buffer.byteLength(body), + const req = request( + { + hostname: url.hostname, + path: url.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + "Content-Length": Buffer.byteLength(body), + }, }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("error", (err: Error) => settle(() => reject(err))); - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - settle(() => { - try { - const text = Buffer.concat(chunks).toString("utf-8"); - const status = res.statusCode ?? 0; - if (status < 200 || status >= 300) { - reject(new Error(`Linear API returned HTTP ${status}: ${text.slice(0, 200)}`)); - return; + (res) => { + const chunks: Buffer[] = []; + res.on("error", (err: Error) => + settle(() => reject(new LinearNetworkError(err.message))), + ); + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + settle(() => { + try { + const text = Buffer.concat(chunks).toString("utf-8"); + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + reject(new LinearHttpError(status, text)); + return; + } + const json: LinearResponse = JSON.parse(text); + if (json.errors && json.errors.length > 0) { + reject(new Error(`Linear API error: ${json.errors[0].message}`)); + return; + } + if (!json.data) { + reject(new Error("Linear API returned no data")); + return; + } + resolve(json.data); + } catch (err) { + reject(err); } - const json: LinearResponse = JSON.parse(text); - if (json.errors && json.errors.length > 0) { - reject(new Error(`Linear API error: ${json.errors[0].message}`)); - return; - } - if (!json.data) { - reject(new Error("Linear API returned no data")); - return; - } - resolve(json.data); - } catch (err) { - reject(err); - } + }); }); - }); - }, - ); + }, + ); - req.setTimeout(30_000, () => { - settle(() => { - req.destroy(); - reject(new Error("Linear API request timed out after 30s")); + req.setTimeout(30_000, () => { + settle(() => { + req.destroy(); + recordTransportActivityEvent({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + summary: "Linear API request timed out after 30s", + data: { + plugin: "tracker-linear", + transport: "direct", + timeoutMs: 30_000, + }, + }); + reject(new LinearTimeoutError()); + }); }); + + req.on("error", (err) => settle(() => reject(new LinearNetworkError(err.message)))); + req.write(body); + req.end(); }); - req.on("error", (err) => settle(() => reject(err))); - req.write(body); - req.end(); - }); + for (let attempt = 1; attempt <= DIRECT_TRANSPORT_MAX_ATTEMPTS; attempt++) { + try { + return await execute(); + } catch (err) { + const shouldRetry = + isRetryableDirectTransportError(err) && attempt < DIRECT_TRANSPORT_MAX_ATTEMPTS; + if (!shouldRetry) throw err; + await sleep(DIRECT_TRANSPORT_RETRY_DELAY_MS * attempt); + } + } + + throw new Error("unreachable"); }; } @@ -147,6 +229,21 @@ function createComposioTransport(apiKey: string, entityId: string): GraphQLTrans msg.includes("Cannot find package") || msg.includes("ERR_MODULE_NOT_FOUND") ) { + // User-actionable, system-wide. Emit once per process. + if (!depMissingEmitted) { + depMissingEmitted = true; + recordActivityEvent({ + source: "tracker", + kind: "tracker.dep_missing", + level: "error", + summary: "Composio SDK (@composio/core) is not installed", + data: { + plugin: "tracker-linear", + package: "@composio/core", + installHint: "pnpm add @composio/core", + }, + }); + } throw new Error( "Composio SDK (@composio/core) is not installed. " + "Install it with: pnpm add @composio/core", @@ -175,6 +272,17 @@ function createComposioTransport(apiKey: string, entityId: string): GraphQLTrans let timer: ReturnType | undefined; const timeoutPromise = new Promise((_resolve, reject) => { timer = setTimeout(() => { + recordTransportActivityEvent({ + source: "tracker", + kind: "tracker.api_timeout", + level: "warn", + summary: "Composio Linear API request timed out after 30s", + data: { + plugin: "tracker-linear", + transport: "composio", + timeoutMs: 30_000, + }, + }); reject(new Error("Composio Linear API request timed out after 30s")); }, 30_000); }); diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index d96d21e55..d31f562ac 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -136,6 +136,40 @@ function mockHTTPError(statusCode: number, body: string) { ); } +/** Queue a request-level network error before Linear returns a response. */ +function mockRequestError(message: string) { + requestMock.mockImplementationOnce(() => { + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + process.nextTick(() => req.emit("error", new Error(message))); + }), + destroy: vi.fn(), + setTimeout: vi.fn(), + }); + return req; + }); +} + +/** Queue a client-side timeout before Linear returns a response. */ +function mockRequestTimeout() { + requestMock.mockImplementationOnce(() => { + let timeoutHandler: (() => void) | undefined; + const req = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(() => { + process.nextTick(() => timeoutHandler?.()); + }), + destroy: vi.fn(), + setTimeout: vi.fn((_ms: number, handler: () => void) => { + timeoutHandler = handler; + return req; + }), + }); + return req; + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -271,11 +305,41 @@ describe("tracker-linear plugin", () => { }); it("throws on HTTP errors", async () => { - mockHTTPError(500, "Internal Server Error"); + mockHTTPError(400, "Bad Request"); await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( - "Linear API returned HTTP 500", + "Linear API returned HTTP 400", ); }); + + it("retries transient HTTP errors", async () => { + mockHTTPError(502, "Bad Gateway"); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + it("retries request-level network errors", async () => { + mockRequestError("ECONNRESET"); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + it("retries client-side request timeouts", async () => { + mockRequestTimeout(); + mockLinearAPI({ issue: sampleIssueNode }); + + const issue = await tracker.getIssue("INT-123", project); + + expect(issue.id).toBe("INT-123"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); }); // ---- isCompleted ------------------------------------------------------- diff --git a/packages/plugins/workspace-clone/src/__tests__/index.test.ts b/packages/plugins/workspace-clone/src/__tests__/index.test.ts index 2b194ccd3..0c2b1da45 100644 --- a/packages/plugins/workspace-clone/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-clone/src/__tests__/index.test.ts @@ -3,6 +3,20 @@ import * as childProcess from "node:child_process"; import * as fs from "node:fs"; import type { ProjectConfig } from "@aoagents/ao-core"; +const { getShellMock, recordActivityEventMock } = vi.hoisted(() => ({ + getShellMock: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })), + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + getShell: getShellMock, + recordActivityEvent: recordActivityEventMock, + }; +}); + // Mock node:child_process with custom promisify support vi.mock("node:child_process", () => { const mockExecFile = vi.fn(); @@ -10,10 +24,6 @@ vi.mock("node:child_process", () => { return { execFile: mockExecFile }; }); -vi.mock("@aoagents/ao-core", () => ({ - getShell: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })), -})); - // Mock node:fs vi.mock("node:fs", () => ({ existsSync: vi.fn(), @@ -267,9 +277,48 @@ describe("workspace.create()", () => { cwd: "/mock-home/.ao-clones/proj/sess", }); + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + projectId: "proj", + sessionId: "sess", + data: expect.objectContaining({ + plugin: "workspace-clone", + branch: "feat/existing", + errorMessage: expect.stringContaining("already exists"), + }), + }), + ); expect(info.branch).toBe("feat/existing"); }); + it("does not emit branch_collision when checkout -b fails for a non-collision reason", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + // git checkout -b fails for a non-collision reason + mockGitError("fatal: cannot lock ref 'refs/heads/feat/locked': Permission denied"); + // git checkout (plain) succeeds + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/locked", + project: makeProject(), + }); + + expect(info.branch).toBe("feat/locked"); + const branchCollisionCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "workspace.branch_collision", + ); + expect(branchCollisionCalls).toHaveLength(0); + }); + it("cleans up partial clone on clone failure", async () => { const workspace = create(); @@ -588,6 +637,41 @@ describe("workspace.list()", () => { warnSpy.mockRestore(); }); + it("emits corrupt_clone_skipped only once per clone path", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "corrupt-repeat", isDirectory: () => true }, + ]); + + mockGitError("fatal: not a git repository"); + mockGitError("fatal: not a git repository"); + + await expect(workspace.list("myproject")).resolves.toEqual([]); + await expect(workspace.list("myproject")).resolves.toEqual([]); + + const corruptCloneCalls = recordActivityEventMock.mock.calls.filter( + ([event]) => event.kind === "workspace.corrupt_clone_skipped", + ); + expect(corruptCloneCalls).toHaveLength(1); + expect(corruptCloneCalls[0][0]).toEqual( + expect.objectContaining({ + projectId: "myproject", + sessionId: "corrupt-repeat", + source: "workspace", + kind: "workspace.corrupt_clone_skipped", + data: expect.objectContaining({ + clonePath: "/mock-home/.ao-clones/myproject/corrupt-repeat", + }), + }), + ); + + warnSpy.mockRestore(); + }); + it("rejects invalid projectId with special characters", async () => { const workspace = create(); diff --git a/packages/plugins/workspace-clone/src/index.ts b/packages/plugins/workspace-clone/src/index.ts index 075fc3119..71100644a 100644 --- a/packages/plugins/workspace-clone/src/index.ts +++ b/packages/plugins/workspace-clone/src/index.ts @@ -3,7 +3,15 @@ import { promisify } from "node:util"; import { existsSync, rmSync, mkdirSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { getShell, type PluginModule, type Workspace, type WorkspaceCreateConfig, type WorkspaceInfo, type ProjectConfig } from "@aoagents/ao-core"; +import { + getShell, + recordActivityEvent, + type PluginModule, + type ProjectConfig, + type Workspace, + type WorkspaceCreateConfig, + type WorkspaceInfo, +} from "@aoagents/ao-core"; const execFileAsync = promisify(execFile); @@ -37,6 +45,16 @@ function expandPath(p: string): string { return p; } +function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isBranchAlreadyExistsError(err: unknown): boolean { + return getErrorMessage(err).toLowerCase().includes("already exists"); +} + +const emittedCorruptClonePaths = new Set(); + export function create(config?: Record): Workspace { const cloneBaseDir = config?.cloneDir ? expandPath(config.cloneDir as string) @@ -97,8 +115,24 @@ export function create(config?: Record): Workspace { // Create and checkout the feature branch try { await git(clonePath, "checkout", "-b", cfg.branch); - } catch { - // Branch may exist on remote — try plain checkout + } catch (branchErr: unknown) { + // Branch may exist on remote — try plain checkout, but only label it + // as a branch_collision when git reported the distinct collision shape. + if (isBranchAlreadyExistsError(branchErr)) { + recordActivityEvent({ + projectId: cfg.projectId, + sessionId: cfg.sessionId, + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + summary: `branch "${cfg.branch}" already exists; falling back to checkout`, + data: { + plugin: "workspace-clone", + branch: cfg.branch, + errorMessage: getErrorMessage(branchErr), + }, + }); + } try { await git(clonePath, "checkout", cfg.branch); } catch (checkoutErr: unknown) { @@ -142,10 +176,27 @@ export function create(config?: Record): Workspace { try { branch = await git(clonePath, "branch", "--show-current"); } catch (err: unknown) { - // Warn about corrupted clones instead of silently skipping + // Warn about corrupted clones instead of silently skipping. + // RCA: "session shows up on disk but isn't returned by list()". const msg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console -- expected diagnostic for corrupted clones console.warn(`[workspace-clone] Skipping "${entry.name}": not a valid git repo (${msg})`); + if (!emittedCorruptClonePaths.has(clonePath)) { + emittedCorruptClonePaths.add(clonePath); + recordActivityEvent({ + projectId, + sessionId: entry.name, + source: "workspace", + kind: "workspace.corrupt_clone_skipped", + level: "warn", + summary: `skipped corrupt clone "${entry.name}"`, + data: { + plugin: "workspace-clone", + clonePath, + errorMessage: msg, + }, + }); + } continue; } diff --git a/packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts b/packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts new file mode 100644 index 000000000..3a2f6039c --- /dev/null +++ b/packages/plugins/workspace-worktree/src/__tests__/activity-events.test.ts @@ -0,0 +1,192 @@ +/** + * Regression tests for plugin-internal activity events (issue #1659). + * + * Covers the MUST emit: workspace.post_create_failed, plus the SHOULDs + * workspace.branch_collision and workspace.destroy_fell_back. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — declared before any import that uses the mocked modules +// --------------------------------------------------------------------------- + +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async () => { + const actual = (await vi.importActual("@aoagents/ao-core")) as Record; + return { + ...actual, + recordActivityEvent: recordActivityEventMock, + }; +}); + +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + (mockExecFile as unknown as Record)[Symbol.for("nodejs.util.promisify.custom")] = + vi.fn(); + return { execFile: mockExecFile }; +}); + +vi.mock("node:fs", () => ({ + cpSync: vi.fn(), + existsSync: vi.fn(() => false), + linkSync: vi.fn(), + lstatSync: vi.fn(), + statSync: vi.fn(), + symlinkSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), +})); + +vi.mock("node:os", () => ({ homedir: () => "/mock-home" })); + +import * as childProcess from "node:child_process"; +import { create } from "../index.js"; +import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoagents/ao-core/types"; + +const mockExecFileAsync = (childProcess.execFile as unknown as Record)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +function makeProject(overrides?: Partial): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; +} + +function makeCreateConfig(overrides?: Partial): WorkspaceCreateConfig { + return { + projectId: "myproject", + project: makeProject(), + sessionId: "session-1", + branch: "feat/TEST-1", + ...overrides, + }; +} + +function makeWorkspaceInfo(): WorkspaceInfo { + return { + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("workspace.post_create_failed (MUST emit)", () => { + it("emits when a postCreate command fails, then rethrows", async () => { + const ws = create(); + const project = makeProject({ postCreate: ["pnpm install"] }); + + mockExecFileAsync.mockRejectedValueOnce(new Error("Command failed: exit 127")); + + await expect(ws.postCreate!(makeWorkspaceInfo(), project)).rejects.toThrow( + "Command failed: exit 127", + ); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.post_create_failed", + level: "error", + sessionId: "session-1", + projectId: "myproject", + data: expect.objectContaining({ + plugin: "workspace-worktree", + command: "pnpm install", + errorMessage: expect.stringContaining("exit 127"), + }), + }), + ); + }); + + it("does NOT emit when postCreate command succeeds", async () => { + const ws = create(); + const project = makeProject({ postCreate: ["echo hi"] }); + + mockExecFileAsync.mockResolvedValueOnce({ stdout: "hi\n", stderr: "" }); + + await ws.postCreate!(makeWorkspaceInfo(), project); + + expect(recordActivityEventMock).not.toHaveBeenCalled(); + }); +}); + +describe("workspace.branch_collision (SHOULD emit)", () => { + it("emits when worktree add -b fails because branch already exists", async () => { + const ws = create(); + + // git remote get-url origin + mockExecFileAsync.mockResolvedValueOnce({ stdout: "origin\n", stderr: "" }); + // git fetch origin --quiet + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + // resolveBaseRef -> rev-parse origin/main + mockExecFileAsync.mockResolvedValueOnce({ stdout: "abc\n", stderr: "" }); + // git worktree add -b ... → already exists + mockExecFileAsync.mockRejectedValueOnce( + new Error("fatal: a branch named 'feat/TEST-1' already exists"), + ); + // rev-parse baseRef for stale-branch comparison + mockExecFileAsync.mockResolvedValueOnce({ stdout: "abc\n", stderr: "" }); + // refExists(branchRef) -> true + mockExecFileAsync.mockResolvedValueOnce({ stdout: "refs/heads/feat/TEST-1\n", stderr: "" }); + // rev-parse existing branch -> same as base, so reuse it + mockExecFileAsync.mockResolvedValueOnce({ stdout: "abc\n", stderr: "" }); + // git worktree add (without -b) — succeeds + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await ws.create(makeCreateConfig()); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + projectId: "myproject", + sessionId: "session-1", + data: expect.objectContaining({ + plugin: "workspace-worktree", + branch: "feat/TEST-1", + errorMessage: expect.stringContaining("already exists"), + }), + }), + ); + }); +}); + +describe("workspace.destroy_fell_back (SHOULD emit)", () => { + it("emits when destroy() falls back to rmSync after git failure", async () => { + const ws = create(); + + // git rev-parse --git-common-dir → fails + mockExecFileAsync.mockRejectedValueOnce(new Error("not a git repository")); + + await ws.destroy("/some/stale/path"); + + expect(recordActivityEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: "workspace", + kind: "workspace.destroy_fell_back", + level: "warn", + data: expect.objectContaining({ + plugin: "workspace-worktree", + workspacePath: "/some/stale/path", + errorMessage: expect.stringContaining("not a git repository"), + }), + }), + ); + }); +}); diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index f4837fd62..c39afd6d9 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -5,6 +5,10 @@ import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@aoage // Mocks — must be declared before any import that uses the mocked modules // --------------------------------------------------------------------------- +const { recordActivityEventMock } = vi.hoisted(() => ({ + recordActivityEventMock: vi.fn(), +})); + vi.mock("node:child_process", () => { const mockExecFile = vi.fn(); // Set custom promisify so `promisify(execFile)` returns { stdout, stderr } @@ -27,6 +31,7 @@ vi.mock("node:fs", () => ({ vi.mock("@aoagents/ao-core", () => ({ getShell: vi.fn(() => ({ cmd: "sh", args: (c: string) => ["-c", c] })), isWindows: vi.fn(() => false), + recordActivityEvent: recordActivityEventMock, })); vi.mock("node:os", () => ({ diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index 7e0b6f61e..1f5c32840 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -16,6 +16,7 @@ import { homedir } from "node:os"; import { getShell, isWindows, + recordActivityEvent, type PluginModule, type Workspace, type WorkspaceCreateConfig, @@ -361,7 +362,21 @@ export function create(config?: Record): Workspace { // Branch already exists. It may be a stale session branch left behind // from an earlier spawn, so compare it with the freshly-resolved base - // before reusing it. + // before reusing it. Surface the collision shape for RCA before the + // recovery path decides whether to reuse or reset the local branch. + recordActivityEvent({ + projectId: cfg.projectId, + sessionId: cfg.sessionId, + source: "workspace", + kind: "workspace.branch_collision", + level: "warn", + summary: `branch "${cfg.branch}" already exists; falling back to worktree recovery`, + data: { + plugin: "workspace-worktree", + branch: cfg.branch, + errorMessage: msg, + }, + }); const baseSha = await git(repoPath, "rev-parse", baseRef); const branchRef = `refs/heads/${cfg.branch}`; const existingBranchSha = (await refExists(repoPath, branchRef)) @@ -453,8 +468,24 @@ export function create(config?: Record): Workspace { // pre-existing local branches unrelated to this workspace (any branch // containing "/" would have been deleted). Stale branches can be // cleaned up separately via `git branch --merged` or similar. - } catch { + } catch (err) { // If git commands fail, try to clean up the directory. + // The worktree metadata may be left stale in `git worktree list` + // because we couldn't run `worktree remove`. Surface so RCA can + // explain why a path was deleted but `git worktree list` still + // references it. + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + source: "workspace", + kind: "workspace.destroy_fell_back", + level: "warn", + summary: "destroy fell back to rmSync; git worktree metadata may be stale", + data: { + plugin: "workspace-worktree", + workspacePath, + errorMessage, + }, + }); // On Windows, retry with backoff for the file-handle drain race // (just-killed pty-host children still hold handles inside the worktree). if (existsSync(workspacePath)) { @@ -661,10 +692,31 @@ export function create(config?: Record): Workspace { if (project.postCreate) { const shell = getShell(); for (const command of project.postCreate) { - await execFileAsync(shell.cmd, shell.args(command), { - cwd: info.path, - windowsHide: true, - }); + try { + await execFileAsync(shell.cmd, shell.args(command), { + cwd: info.path, + windowsHide: true, + }); + } catch (err) { + // Surface which postCreate command failed. Lifecycle records + // a generic spawn_failed but loses the specific command and + // its sanitized error output. + const errorMessage = err instanceof Error ? err.message : String(err); + recordActivityEvent({ + projectId: info.projectId, + sessionId: info.sessionId, + source: "workspace", + kind: "workspace.post_create_failed", + level: "error", + summary: `postCreate command failed for session ${info.sessionId}`, + data: { + plugin: "workspace-worktree", + command, + errorMessage, + }, + }); + throw err; + } } } }, diff --git a/packages/web/e2e/review-board-flows.md b/packages/web/e2e/review-board-flows.md new file mode 100644 index 000000000..a8b4cc706 --- /dev/null +++ b/packages/web/e2e/review-board-flows.md @@ -0,0 +1,110 @@ +# Review Board E2E Flows + +These flows cover the reviewer-agent UI as an orchestrator-owned surface, not as a second +command center. The project orchestrator is the entry point for creating and running reviews. +The review board observes, inspects, and navigates reviewer work. Reviewer runs remain linked to +a coding worker, but they must not reuse the worker's terminal context. + +## Flow 1: Enter through the project orchestrator + +1. Open the project coding dashboard at `/projects/:projectId`. +2. Open the header `Orchestrator` action. +3. Confirm the app lands on `/projects/:projectId/sessions/:orchestratorId`. +4. Confirm this is the project orchestrator session, not a worker or reviewer session. + +## Flow 2: Coding to Reviews navigation stays available + +1. Open the project coding dashboard at `/projects/:projectId`. +2. Confirm the shared header shows `Coding` as the active workspace mode. +3. Click `Reviews`. +4. Confirm the browser lands on `/review?project=:projectId`. +5. Confirm `Reviews` is now active and `Coding` links back to `/projects/:projectId`. + +## Flow 3: Orchestrator requests reviews + +1. Start with a worker card that is ready for review. +2. From the orchestrator flow, issue the AO review command for that worker. +3. Confirm a queued review run appears on the review board. +4. Confirm the review run is linked to the coding worker and displays worker metadata. +5. Confirm no reviewer coding session metadata is created. + +## Flow 4: Orchestrator executes multiple queued reviewer runs + +1. Create at least two queued review runs. +2. From the orchestrator flow, issue two reviewer execution commands without waiting between them. +3. Confirm both cards can be observed in a reviewing state. +4. Confirm completed runs move to either `Triage` when findings exist or `Clean` when no findings exist. + +## Flow 5: Inspect findings + +1. Let the orchestrator execute a run whose reviewer result contains a finding. +2. Confirm the run lands in `Triage`. +3. Click the `view` finding action or the card `details` action. +4. Confirm the details panel lists severity, title, location, body, open count, total count, and worker actions. +5. Close the drawer with the close button or Escape. + +## Flow 6: Worker and orchestrator links + +1. From a review card, click `Worker`. +2. Confirm the app navigates to the coding dashboard focused on the linked worker. +3. Return to the review board. +4. Confirm the header `Orchestrator` action opens or restores the same project orchestrator used by the coding dashboard. + +## Flow 7: Failure and retry + +1. Execute a queued run with a reviewer command that fails. +2. Confirm the card moves to `Failed`. +3. From the orchestrator flow, issue a retry command for the same run. +4. Confirm the retry can execute the same run again with `force: true`. + +## Flow 8: Feedback availability + +1. Show a review run linked to a worker with no live runtime. +2. Confirm the card shows the worker runtime state instead of a `Feedback` action. +3. Show a review run linked to a live worker. +4. Confirm `Feedback` sends open review findings to the linked worker. +5. Confirm the app opens the worker terminal section after the send succeeds. + +## Flow 9: CLI and UI share the same review store + +1. Request a review through the orchestrator AO command path. +2. Confirm the run appears in `/review?project=:projectId` without refreshing any mocked data. +3. Execute that run through the orchestrator AO command path and a deterministic local reviewer command. +4. Confirm the UI reflects the persisted result after a reload. + +## Flow 10: Clean review result + +1. Let the orchestrator execute a reviewer command that returns `{"findings":[]}`. +2. Confirm the run moves to `Clean`. +3. Confirm the card reports `0 findings`. +4. Open details and confirm it reports no captured findings. + +## Flow 11: Reviewer isolation from coding sessions + +1. Execute a reviewer run. +2. Confirm the reviewer has a snapshot workspace under `code-reviews/workspaces/:reviewerSessionId`. +3. Confirm there is no coding session metadata file for `:reviewerSessionId`. +4. Confirm the linked worker card and terminal route remain the coding worker, not the reviewer. + +## Flow 12: New worker commit supersedes old review runs + +1. Complete a review for the worker's current `HEAD`. +2. Commit a new change in the worker repository. +3. From the orchestrator flow, request a new review for the same worker. +4. Confirm older review runs for the previous `HEAD` move to `Outdated`. +5. Confirm the new review remains actionable in `Queued`. + +## Flow 13: Same orchestrator across modes + +1. Open the coding dashboard and capture the header `Orchestrator` link. +2. Open the review board and capture the header `Orchestrator` link. +3. Confirm both links point to the same project orchestrator session. +4. Confirm the review board does not offer `Spawn Orchestrator` when one already exists. + +## Flow 14: Send reviewer findings back to worker + +1. Complete a review run with open findings. +2. From the review board, click `Feedback` for that review run. +3. Confirm AO sends the stored finding details to the linked coding worker. +4. Confirm the review run moves to `Waiting`. +5. Confirm open findings become sent findings and are no longer counted as open. diff --git a/packages/web/e2e/review-board.e2e.ts b/packages/web/e2e/review-board.e2e.ts new file mode 100644 index 000000000..f4e966ece --- /dev/null +++ b/packages/web/e2e/review-board.e2e.ts @@ -0,0 +1,871 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { request } from "node:http"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, type Locator, type Page } from "playwright"; +import { createCodeReviewStore } from "../../core/src/code-review-store.ts"; +import { + createInitialCanonicalLifecycle, + deriveLegacyStatus, +} from "../../core/src/lifecycle-state.ts"; +import { writeMetadata } from "../../core/src/metadata.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WEB_DIR = resolve(__dirname, ".."); +const REPO_ROOT = resolve(WEB_DIR, "../.."); +const PROJECT_ID = "todo-app"; +const SESSION_ID = "todo-1"; +const ORCHESTRATOR_ID = "todo-orchestrator"; +const PR_URL = "https://github.com/acme/todo-app/pull/1"; + +interface Fixture { + rootDir: string; + homeDir: string; + projectDir: string; + globalConfigPath: string; + localConfigPath: string; + tmuxSessionPrefix: string; + tmuxSessions: string[]; +} + +interface ServerHandle { + baseUrl: string; + stop: () => Promise; +} + +function shellJson(value: unknown): string { + return JSON.stringify(value).replaceAll("'", "'\"'\"'"); +} + +function buildStaticReviewCommand(findings: unknown[], delayMs = 0): string { + const payload = { findings }; + return `node -e 'setTimeout(() => console.log(${shellJson(JSON.stringify(payload))}), ${delayMs})'`; +} + +function buildFindingReviewCommand(delayMs: number): string { + return buildStaticReviewCommand( + [ + { + severity: "warning", + title: "E2E reviewer finding", + body: "The e2e review command created this finding.", + filePath: "README.md", + startLine: 1, + confidence: 0.9, + }, + ], + delayMs, + ); +} + +function git(cwd: string, args: string[]): void { + execFileSync("git", args, { + cwd, + stdio: "ignore", + env: { + ...process.env, + GIT_AUTHOR_NAME: "AO E2E", + GIT_AUTHOR_EMAIL: "ao-e2e@example.com", + GIT_COMMITTER_NAME: "AO E2E", + GIT_COMMITTER_EMAIL: "ao-e2e@example.com", + }, + }); +} + +function startCodexBackedTmux(sessionName: string, cwd: string): void { + execFileSync( + "tmux", + [ + "new-session", + "-d", + "-s", + sessionName, + "-c", + cwd, + "exec codex --no-alt-screen --sandbox danger-full-access --ask-for-approval never", + ], + { stdio: "ignore" }, + ); +} + +function killTmuxSession(sessionName: string): void { + try { + execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" }); + } catch { + // Best effort cleanup for local e2e fixtures. + } +} + +function listTmuxSessions(): string[] { + try { + return execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }) + .split(/\r?\n/) + .map((sessionName) => sessionName.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function killReviewBoardTmuxSessions(fixture: Fixture): void { + const sessionNames = new Set([ + ...fixture.tmuxSessions, + ...listTmuxSessions().filter((sessionName) => + sessionName.startsWith(fixture.tmuxSessionPrefix), + ), + ]); + + for (const sessionName of sessionNames) { + killTmuxSession(sessionName); + } +} + +function installFixtureSignalCleanup(fixture: Fixture): void { + process.once("exit", () => killReviewBoardTmuxSessions(fixture)); + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + killReviewBoardTmuxSessions(fixture); + process.exit(signal === "SIGINT" ? 130 : 143); + }); + } +} + +function captureTmuxPane(sessionName: string): string { + try { + return execFileSync("tmux", ["capture-pane", "-p", "-t", sessionName], { + encoding: "utf-8", + }); + } catch { + return ""; + } +} + +async function waitForTmuxText( + sessionName: string, + pattern: RegExp, + label: string, + timeoutMs = 45_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastCapture = ""; + + while (Date.now() < deadline) { + lastCapture = captureTmuxPane(sessionName); + if (pattern.test(lastCapture)) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + + throw new Error(`Expected tmux text: ${label}\n${lastCapture}`); +} + +async function getFreePort(): Promise { + return new Promise((resolvePromise, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Could not allocate a TCP port"))); + return; + } + const port = address.port; + server.close(() => resolvePromise(port)); + }); + }); +} + +function probe(url: URL): Promise { + return new Promise((resolveProbe) => { + const req = request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: "HEAD", + timeout: 2_000, + }, + (res) => { + res.resume(); + resolveProbe(true); + }, + ); + req.on("error", () => resolveProbe(false)); + req.on("timeout", () => { + req.destroy(); + resolveProbe(false); + }); + req.end(); + }); +} + +async function waitForServer(baseUrl: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + const url = new URL("/projects/todo-app", baseUrl); + while (Date.now() < deadline) { + if (await probe(url)) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + throw new Error(`Next dev server did not respond at ${baseUrl} within ${timeoutMs}ms`); +} + +async function startWebServer(fixture: Fixture): Promise { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const output: string[] = []; + const child: ChildProcess = spawn("pnpm", ["dev:next"], { + cwd: WEB_DIR, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: fixture.homeDir, + AO_GLOBAL_CONFIG: fixture.globalConfigPath, + AO_CONFIG_PATH: fixture.localConfigPath, + AO_CODE_REVIEW_COMMAND: buildFindingReviewCommand(1_000), + PORT: String(port), + NEXT_TELEMETRY_DISABLED: "1", + AO_NO_UPDATE_NOTIFIER: "1", + AGENT_ORCHESTRATOR_CI: "1", + }, + }); + + const collect = (chunk: Buffer) => { + output.push(chunk.toString()); + if (output.length > 80) output.splice(0, output.length - 80); + }; + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + try { + await waitForServer(baseUrl, 45_000); + } catch (error) { + child.kill("SIGTERM"); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n${output.join("").trim()}`, + { cause: error }, + ); + } + + return { + baseUrl, + stop: async () => { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolveStop) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolveStop(); + }, 5_000); + child.once("exit", () => { + clearTimeout(timer); + resolveStop(); + }); + }); + }, + }; +} + +function createFixture(): Fixture { + const rootDir = mkdtempSync(join(tmpdir(), "ao-review-board-e2e-")); + const homeDir = join(rootDir, "home"); + const projectDir = join(rootDir, "todo-app"); + const globalConfigPath = join(homeDir, ".agent-orchestrator", "config.yaml"); + const localConfigPath = join(projectDir, "agent-orchestrator.yaml"); + const sessionsDir = join(homeDir, ".agent-orchestrator", "projects", PROJECT_ID, "sessions"); + const tmuxSuffix = basename(rootDir).replace(/[^a-zA-Z0-9_-]/g, "-"); + const tmuxSessionPrefix = `${tmuxSuffix}-`; + const workerTmuxName = `${tmuxSuffix}-worker`; + const orchestratorTmuxName = `${tmuxSuffix}-orchestrator`; + + mkdirSync(dirname(globalConfigPath), { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(sessionsDir, { recursive: true }); + + writeFileSync(join(projectDir, "README.md"), "# Todo App\n"); + writeFileSync(localConfigPath, "agent: codex\nruntime: tmux\nworkspace: worktree\n"); + git(projectDir, ["init", "-b", "main"]); + git(projectDir, ["add", "."]); + git(projectDir, ["commit", "-m", "initial"]); + startCodexBackedTmux(workerTmuxName, projectDir); + startCodexBackedTmux(orchestratorTmuxName, REPO_ROOT); + + writeFileSync( + globalConfigPath, + [ + "defaults:", + " runtime: tmux", + " agent: codex", + " workspace: worktree", + " notifiers: []", + "notifiers: {}", + "projects:", + ` ${PROJECT_ID}:`, + ` path: ${JSON.stringify(projectDir)}`, + " displayName: Todo App", + " defaultBranch: main", + " sessionPrefix: todo", + "", + ].join("\n"), + ); + + process.env["HOME"] = homeDir; + process.env["AO_GLOBAL_CONFIG"] = globalConfigPath; + process.env["AO_CONFIG_PATH"] = localConfigPath; + + const createdAt = new Date("2026-05-13T10:00:00.000Z"); + const workerLifecycle = createInitialCanonicalLifecycle("worker", createdAt); + workerLifecycle.session.state = "idle"; + workerLifecycle.session.reason = "awaiting_external_review"; + workerLifecycle.session.startedAt = createdAt.toISOString(); + workerLifecycle.session.lastTransitionAt = createdAt.toISOString(); + workerLifecycle.pr.state = "open"; + workerLifecycle.pr.reason = "review_pending"; + workerLifecycle.pr.number = 1; + workerLifecycle.pr.url = PR_URL; + workerLifecycle.pr.lastObservedAt = createdAt.toISOString(); + workerLifecycle.runtime.state = "alive"; + workerLifecycle.runtime.reason = "process_running"; + workerLifecycle.runtime.lastObservedAt = createdAt.toISOString(); + workerLifecycle.runtime.handle = { id: workerTmuxName, runtimeName: "tmux", data: {} }; + workerLifecycle.runtime.tmuxName = workerTmuxName; + + writeMetadata(sessionsDir, SESSION_ID, { + worktree: projectDir, + branch: "main", + status: deriveLegacyStatus(workerLifecycle), + lifecycle: workerLifecycle, + project: PROJECT_ID, + pr: PR_URL, + displayName: "E2E todo review target", + agent: "codex", + createdAt: createdAt.toISOString(), + tmuxName: workerTmuxName, + runtimeHandle: { id: workerTmuxName, runtimeName: "tmux", data: {} }, + }); + + const orchestratorLifecycle = createInitialCanonicalLifecycle("orchestrator", createdAt); + orchestratorLifecycle.session.state = "working"; + orchestratorLifecycle.session.reason = "task_in_progress"; + orchestratorLifecycle.session.startedAt = createdAt.toISOString(); + orchestratorLifecycle.session.lastTransitionAt = createdAt.toISOString(); + orchestratorLifecycle.runtime.state = "alive"; + orchestratorLifecycle.runtime.reason = "process_running"; + orchestratorLifecycle.runtime.lastObservedAt = createdAt.toISOString(); + orchestratorLifecycle.runtime.handle = { + id: orchestratorTmuxName, + runtimeName: "tmux", + data: {}, + }; + orchestratorLifecycle.runtime.tmuxName = orchestratorTmuxName; + + writeMetadata(sessionsDir, ORCHESTRATOR_ID, { + worktree: join(rootDir, "orchestrator-worktree"), + branch: "orchestrator/todo-orchestrator", + status: deriveLegacyStatus(orchestratorLifecycle), + lifecycle: orchestratorLifecycle, + role: "orchestrator", + project: PROJECT_ID, + displayName: "# Todo App Orchestrator", + agent: "codex", + createdAt: createdAt.toISOString(), + tmuxName: orchestratorTmuxName, + runtimeHandle: { id: orchestratorTmuxName, runtimeName: "tmux", data: {} }, + }); + + const store = createCodeReviewStore(PROJECT_ID); + store.deleteAll(); + store.createRun({ + linkedSessionId: SESSION_ID, + reviewerSessionId: "todo-rev-failed", + status: "failed", + prNumber: 1, + prUrl: PR_URL, + summary: "Seeded failed run for retry coverage.", + }); + + return { + rootDir, + homeDir, + projectDir, + globalConfigPath, + localConfigPath, + tmuxSessionPrefix, + tmuxSessions: [workerTmuxName, orchestratorTmuxName], + }; +} + +function reviewCard(page: Page, reviewerSessionId: string): Locator { + return page.locator(`[data-reviewer-session-id="${reviewerSessionId}"]`); +} + +function projectAoDir(fixture: Fixture): string { + return join(fixture.homeDir, ".agent-orchestrator", "projects", PROJECT_ID); +} + +function runAoCli(fixture: Fixture, args: string[]): string { + return execFileSync("pnpm", ["--dir", join(REPO_ROOT, "packages/cli"), "dev", ...args], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: fixture.homeDir, + AO_GLOBAL_CONFIG: fixture.globalConfigPath, + AO_CONFIG_PATH: fixture.localConfigPath, + AO_NO_UPDATE_NOTIFIER: "1", + AGENT_ORCHESTRATOR_CI: "1", + }, + }); +} + +function runAoCliAsync(fixture: Fixture, args: string[]): Promise { + return new Promise((resolveRun, rejectRun) => { + const child = spawn("pnpm", ["--dir", join(REPO_ROOT, "packages/cli"), "dev", ...args], { + cwd: REPO_ROOT, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + HOME: fixture.homeDir, + AO_GLOBAL_CONFIG: fixture.globalConfigPath, + AO_CONFIG_PATH: fixture.localConfigPath, + AO_NO_UPDATE_NOTIFIER: "1", + AGENT_ORCHESTRATOR_CI: "1", + }, + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", rejectRun); + child.once("exit", (code) => { + if (code === 0) { + resolveRun(stdout); + return; + } + rejectRun(new Error(`ao ${args.join(" ")} failed with ${code}\n${stdout}\n${stderr}`)); + }); + }); +} + +function orchestratorReviewRun(fixture: Fixture, args: string[]): unknown { + return parseJsonCommandOutput(runAoCli(fixture, ["review", "run", SESSION_ID, ...args])); +} + +function orchestratorReviewExecute(fixture: Fixture, args: string[]): unknown { + return parseJsonCommandOutput(runAoCli(fixture, ["review", "execute", PROJECT_ID, ...args])); +} + +async function orchestratorReviewExecuteAsync( + fixture: Fixture, + args: string[], +): Promise { + return parseJsonCommandOutput( + await runAoCliAsync(fixture, ["review", "execute", PROJECT_ID, ...args]), + ); +} + +function parseJsonCommandOutput(output: string): unknown { + const lines = output.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index--) { + const line = lines[index]?.trim(); + if (!line || (!line.startsWith("{") && !line.startsWith("["))) continue; + try { + return JSON.parse(lines.slice(index).join("\n")); + } catch { + // Keep scanning for the actual JSON payload. pnpm can echo JSON-looking command args. + } + } + + throw new Error(`Expected JSON command output, received: ${output}`); +} + +async function expectVisible(locator: Locator, label: string): Promise { + try { + await locator.waitFor({ state: "visible", timeout: 15_000 }); + } catch (error) { + throw new Error( + `Expected visible: ${label}\n${error instanceof Error ? error.message : error}`, + { cause: error }, + ); + } +} + +async function clickWorkspaceMode(page: Page, name: "Coding" | "Reviews"): Promise { + await page + .getByRole("navigation", { name: "Workspace mode" }) + .getByRole("link", { name, exact: true }) + .click(); +} + +async function step(name: string, run: () => Promise): Promise { + process.stdout.write(`\n- ${name}\n`); + await run(); +} + +async function main(): Promise { + const fixture = createFixture(); + installFixtureSignalCleanup(fixture); + const server = await startWebServer(fixture); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + + try { + await step("enter the project through its orchestrator", async () => { + await page.goto(`${server.baseUrl}/projects/${PROJECT_ID}/sessions/${ORCHESTRATOR_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(page.getByText("# Todo App Orchestrator").first(), "orchestrator title"); + }); + + await step("navigate between coding and review modes from the shared header", async () => { + await page.goto(`${server.baseUrl}/projects/${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const nav = page.getByRole("navigation", { name: "Workspace mode" }); + await expectVisible(nav.getByRole("link", { name: "Coding", exact: true }), "Coding tab"); + assert.equal( + await nav.getByRole("link", { name: "Coding", exact: true }).getAttribute("aria-current"), + "page", + ); + + await clickWorkspaceMode(page, "Reviews"); + await page.waitForURL(`**/review?project=${PROJECT_ID}`); + const reviewNav = page.getByRole("navigation", { name: "Workspace mode" }); + assert.equal( + await reviewNav + .getByRole("link", { name: "Reviews", exact: true }) + .getAttribute("aria-current"), + "page", + ); + assert.equal( + await reviewNav.getByRole("link", { name: "Coding", exact: true }).getAttribute("href"), + `/projects/${PROJECT_ID}`, + ); + }); + + await step("confirm coding and review modes use the same project orchestrator", async () => { + await page.goto(`${server.baseUrl}/projects/${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const codingOrchestrator = page.getByRole("link", { name: "Orchestrator" }).first(); + await expectVisible(codingOrchestrator, "coding orchestrator link"); + const codingHref = await codingOrchestrator.getAttribute("href"); + assert.equal(codingHref, `/projects/${PROJECT_ID}/sessions/${ORCHESTRATOR_ID}`); + + await clickWorkspaceMode(page, "Reviews"); + await page.waitForURL(`**/review?project=${PROJECT_ID}`); + const reviewOrchestrator = page.getByRole("link", { name: "Open project orchestrator" }); + await expectVisible(reviewOrchestrator, "review orchestrator link"); + assert.equal(await reviewOrchestrator.getAttribute("href"), codingHref); + assert.equal( + await page.getByRole("button", { name: "Spawn Orchestrator" }).count(), + 0, + "review board should reuse the existing orchestrator instead of spawning another", + ); + }); + + await step("orchestrator requests the first review", async () => { + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested initial E2E review", + "--json", + ]) as { run: { reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + assert.equal(requested.run.reviewerSessionId, "todo-rev-1"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await page.waitForURL(`**/review?project=${PROJECT_ID}`); + await expectVisible(reviewCard(page, "todo-rev-1"), "todo-rev-1 queued review card"); + await expectVisible(reviewCard(page, "todo-rev-failed"), "seeded failed retry card"); + }); + + await step("orchestrator requests another review", async () => { + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested parallel E2E review", + "--json", + ]) as { run: { reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + assert.equal(requested.run.reviewerSessionId, "todo-rev-2"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(reviewCard(page, "todo-rev-2"), "todo-rev-2 queued review card"); + }); + + await step("orchestrator runs two queued reviewer runs concurrently", async () => { + const startedAt = Date.now(); + const firstExecution = orchestratorReviewExecuteAsync(fixture, [ + "--run", + "todo-rev-1", + "--command", + buildFindingReviewCommand(8_000), + "--json", + ]); + const secondExecution = orchestratorReviewExecuteAsync(fixture, [ + "--run", + "todo-rev-2", + "--command", + buildFindingReviewCommand(8_000), + "--json", + ]); + + const [firstResult, secondResult] = (await Promise.all([ + firstExecution, + secondExecution, + ])) as [ + { run: { reviewerSessionId: string; status: string } }, + { run: { reviewerSessionId: string; status: string } }, + ]; + assert.equal(firstResult.run.status, "needs_triage"); + assert.equal(secondResult.run.status, "needs_triage"); + assert.ok( + Date.now() - startedAt < 14_000, + "reviewer executions should run concurrently, not serially", + ); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-1"][data-review-status="needs_triage"]'), + "first card in triage", + ); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-2"][data-review-status="needs_triage"]'), + "second card in triage", + ); + }); + + await step("confirm reviewer workspaces stay isolated from coding sessions", async () => { + const aoProjectDir = projectAoDir(fixture); + assert.equal( + existsSync(join(aoProjectDir, "code-reviews", "workspaces", "todo-rev-1")), + true, + "executed reviewer run should have a snapshot workspace", + ); + assert.equal( + existsSync(join(aoProjectDir, "sessions", "todo-rev-1.json")), + false, + "reviewer run must not create coding session metadata", + ); + await expectVisible( + reviewCard(page, "todo-rev-1").getByRole("link", { name: "Worker" }), + "worker link still points at coding worker", + ); + }); + + await step("open findings details from a triage review card", async () => { + const first = reviewCard(page, "todo-rev-1"); + await first.getByRole("button", { name: "view" }).click(); + const dialog = page.getByRole("dialog", { name: /E2E todo review target/i }); + await expectVisible(dialog, "review details dialog"); + await expectVisible(dialog.getByText("E2E reviewer finding"), "finding title"); + await expectVisible(dialog.getByText("README.md:1"), "finding location"); + await expectVisible( + dialog.getByText("The e2e review command created this finding."), + "finding body", + ); + await expectVisible(dialog.getByRole("link", { name: "Open terminal" }), "terminal link"); + await dialog.getByLabel("Close review details").click(); + }); + + await step("review board sends review findings back to the linked worker", async () => { + await reviewCard(page, "todo-rev-1").getByRole("button", { name: "Feedback" }).click(); + await page.waitForURL( + `**/projects/${PROJECT_ID}/sessions/${SESSION_ID}#session-terminal-section`, + ); + + await waitForTmuxText( + fixture.tmuxSessions[0] ?? "", + /E2E reviewer finding/, + "worker receives AO-local review finding", + ); + + const store = createCodeReviewStore(PROJECT_ID); + const sentRun = store + .listRunSummaries() + .find((run) => run.reviewerSessionId === "todo-rev-1"); + assert.ok(sentRun, "sent review run should still exist"); + assert.equal(sentRun.status, "waiting_update"); + assert.equal(sentRun.openFindingCount, 0); + assert.equal(sentRun.sentFindingCount, 1); + + const sentFindings = store.listFindings({ runId: sentRun.id }); + assert.equal(sentFindings.length, 1); + assert.equal(sentFindings[0]?.status, "sent_to_agent"); + assert.ok(sentFindings[0]?.sentToAgentAt, "sent finding should record handoff time"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const sentCard = reviewCard(page, "todo-rev-1"); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-1"][data-review-status="waiting_update"]'), + "sent review moved to waiting", + ); + await expectVisible( + sentCard.getByText(/waiting update · 1 finding · 1 sent/i), + "sent truth line", + ); + assert.equal( + await sentCard.getByRole("button", { name: /open finding/i }).count(), + 0, + "sent findings should not still be counted as open", + ); + }); + + await step("jump from review card back to the linked coding worker", async () => { + await reviewCard(page, "todo-rev-1").getByRole("link", { name: "Worker" }).click(); + await page.waitForURL(`**/projects/${PROJECT_ID}?session=${SESSION_ID}`); + assert.equal(new URL(page.url()).searchParams.get("session"), SESSION_ID); + }); + + await step("orchestrator marks older review runs outdated after a new worker commit", async () => { + writeFileSync(join(fixture.projectDir, "todo.txt"), "new worker commit\n"); + git(fixture.projectDir, ["add", "todo.txt"]); + git(fixture.projectDir, ["commit", "-m", "worker update"]); + + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested review after worker update", + "--json", + ]) as { run: { reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + assert.equal(requested.run.reviewerSessionId, "todo-rev-3"); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(reviewCard(page, "todo-rev-3"), "todo-rev-3 queued review card"); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-1"][data-review-status="outdated"]'), + "older triage review marked outdated", + ); + await expectVisible( + page.locator('[data-reviewer-session-id="todo-rev-2"][data-review-status="outdated"]'), + "second older triage review marked outdated", + ); + }); + + await step("orchestrator runs a clean review and the UI observes it", async () => { + const requested = orchestratorReviewRun(fixture, [ + "--summary", + "Orchestrator requested clean review", + "--json", + ]) as { run: { id: string; reviewerSessionId: string; status: string } }; + assert.equal(requested.run.status, "queued"); + + const executed = orchestratorReviewExecute(fixture, [ + "--run", + requested.run.reviewerSessionId, + "--command", + buildStaticReviewCommand([]), + "--json", + ]) as { run: { reviewerSessionId: string; status: string; findingCount: number } }; + assert.equal(executed.run.status, "clean"); + assert.equal(executed.run.findingCount, 0); + + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + const cleanCard = reviewCard(page, requested.run.reviewerSessionId); + await expectVisible( + cleanCard.locator('[data-review-status="clean"]').or(cleanCard), + "CLI clean review card", + ); + await expectVisible( + page.locator( + `[data-reviewer-session-id="${requested.run.reviewerSessionId}"][data-review-status="clean"]`, + ), + "CLI clean review in clean column", + ); + await expectVisible(cleanCard.getByText(/clean · 0 findings/i), "clean truth line"); + await cleanCard.getByRole("button", { name: "details" }).click(); + const dialog = page.getByRole("dialog", { name: /E2E todo review target/i }); + await expectVisible(dialog.getByText("No findings captured for this run."), "clean details"); + await dialog.getByLabel("Close review details").click(); + }); + + await step("orchestrator retries a failed reviewer run", async () => { + const failed = reviewCard(page, "todo-rev-failed"); + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible(failed, "failed review card"); + const retryPayload = orchestratorReviewExecute(fixture, [ + "--run", + "todo-rev-failed", + "--force", + "--command", + buildFindingReviewCommand(0), + "--json", + ]) as { run: { status?: string; terminationReason?: string } }; + assert.notEqual( + retryPayload.run.status, + "failed", + retryPayload.run.terminationReason ?? "retry should not fail", + ); + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "networkidle", + }); + await expectVisible( + page.locator( + '[data-reviewer-session-id="todo-rev-failed"][data-review-status="needs_triage"]', + ), + "failed card moved to triage after retry", + ); + }); + + await step("confirm review cards expose worker and orchestrator affordances", async () => { + await page.goto(`${server.baseUrl}/review?project=${PROJECT_ID}`, { + waitUntil: "domcontentloaded", + }); + const retried = reviewCard(page, "todo-rev-failed"); + await expectVisible(retried.getByRole("button", { name: "Feedback" }), "feedback button"); + const orchestrator = page.getByRole("link", { name: "Open project orchestrator" }); + await expectVisible(orchestrator, "project orchestrator link"); + assert.equal( + await orchestrator.getAttribute("href"), + `/projects/${PROJECT_ID}/sessions/${ORCHESTRATOR_ID}`, + ); + }); + + process.stdout.write("\nReview board e2e flows passed.\n"); + } finally { + try { + await browser.close(); + } catch { + // Best effort cleanup; tmux sessions and fixture files still need cleanup. + } + try { + await server.stop(); + } catch { + // Best effort cleanup; tmux sessions and fixture files still need cleanup. + } + killReviewBoardTmuxSessions(fixture); + if (process.env["AO_E2E_KEEP_ARTIFACTS"] !== "1") { + rmSync(fixture.rootDir, { recursive: true, force: true }); + } else { + process.stdout.write(`\nKept e2e fixture at ${fixture.rootDir}\n`); + } + } +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/web/package.json b/packages/web/package.json index 5bff12a2a..9ac715576 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -36,6 +36,7 @@ "dev:optimized": "rimraf .next dist-server && next build && tsc -p tsconfig.server.json && node dist-server/start-all.js", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:e2e:review": "tsx e2e/review-board.e2e.ts", "test:watch": "vitest", "clean": "node scripts/guard-production-artifact-clean.mjs && rimraf .next dist-server", "screenshot": "tsx e2e/screenshot.ts", diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index b617c3c32..c8472f08c 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -1,15 +1,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Socket } from "node:net"; +import { WebSocket } from "ws"; +import { appendDashboardNotification, type OrchestratorEvent } from "@aoagents/ao-core"; import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket"; // vi.mock factories run before module-level statements. Hoist the mock // fns so the factories close over the same instances the tests use. -const { mockSpawn, mockPtySpawn, mockTmuxHasSession } = vi.hoisted(() => ({ +const { mockSpawn, mockPtySpawn, mockTmuxHasSession, recordActivityEvent } = vi.hoisted(() => ({ mockSpawn: vi.fn(), mockPtySpawn: vi.fn(), mockTmuxHasSession: vi.fn(), + recordActivityEvent: vi.fn(), })); +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + recordActivityEvent: (event: unknown) => recordActivityEvent(event), + }; +}); + vi.mock("node:child_process", async (importOriginal) => { const actual = (await importOriginal()) as Record; const spawnFn = (...args: unknown[]) => mockSpawn(...args); @@ -34,15 +49,69 @@ vi.mock("../tmux-utils.js", () => ({ findTmux: () => "/usr/bin/tmux", validateSessionId: () => true, resolveTmuxSession: () => "ao-177", + resolvePipePath: () => null, tmuxHasSession: (...args: unknown[]) => mockTmuxHasSession(...args), })); -const { SessionBroadcaster, TerminalManager } = await import("../mux-websocket"); +const { + NotificationBroadcaster, + SessionBroadcaster, + TerminalManager, + createMuxWebSocket, + handleWindowsPipeMessage, +} = await import("../mux-websocket"); // Mock global fetch const mockFetch = vi.fn(); global.fetch = mockFetch; +type MockPty = { + dataHandlers: Array<(data: string) => void>; + exitHandlers: Array<(event: { exitCode: number }) => void>; + onData: ReturnType; + onExit: ReturnType; + write: ReturnType; + resize: ReturnType; + kill: ReturnType; + emitData: (data: string) => void; + emitExit: (exitCode: number) => Promise; +}; + +const ptyInstances: MockPty[] = []; + +function createMockPty(): MockPty { + const pty = {} as MockPty; + pty.dataHandlers = []; + pty.exitHandlers = []; + pty.onData = vi.fn((handler: (data: string) => void) => { + pty.dataHandlers.push(handler); + }); + pty.onExit = vi.fn((handler: (event: { exitCode: number }) => void) => { + pty.exitHandlers.push(handler); + }); + pty.write = vi.fn(); + pty.resize = vi.fn(); + pty.kill = vi.fn(); + pty.emitData = (data: string) => { + for (const handler of pty.dataHandlers) handler(data); + }; + pty.emitExit = async (exitCode: number) => { + await Promise.all([...pty.exitHandlers].map((handler) => handler({ exitCode }))); + }; + ptyInstances.push(pty); + return pty; +} + +function resetPtyMock(): void { + ptyInstances.length = 0; + mockSpawn.mockReset(); + mockPtySpawn.mockReset(); + mockTmuxHasSession.mockReset(); + mockTmuxHasSession.mockResolvedValue(true); + mockSpawn.mockImplementation(() => new EventEmitter()); + mockPtySpawn.mockImplementation(createMockPty); +} + describe("SessionBroadcaster", () => { let broadcaster: SessionBroadcasterType; @@ -50,6 +119,8 @@ describe("SessionBroadcaster", () => { vi.useFakeTimers(); vi.unstubAllEnvs(); mockFetch.mockReset(); + recordActivityEvent.mockClear(); + resetPtyMock(); broadcaster = new SessionBroadcaster("3000"); }); @@ -284,6 +355,589 @@ describe("SessionBroadcaster", () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); }); + + describe("ui.session_broadcast_failed activity events", () => { + function failedKinds(): string[] { + return recordActivityEvent.mock.calls + .map(([e]) => (e as { kind: string }).kind) + .filter((k) => k === "ui.session_broadcast_failed"); + } + + it("emits exactly once on the healthy→failing transition", async () => { + // First fetch fails — triggers emission + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + // Second fetch (3s later) also fails — should NOT emit again + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(3010); + + expect(failedKinds()).toEqual(["ui.session_broadcast_failed"]); + }); + + it("re-arms after recovery (success → failure emits again)", async () => { + // fail → succeed → fail + mockFetch.mockRejectedValueOnce(new Error("net down")); + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ sessions: [] }) }); + mockFetch.mockRejectedValueOnce(new Error("net down again")); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(3010); // poll #1 → success + await vi.advanceTimersByTimeAsync(3010); // poll #2 → failure + + expect(failedKinds().length).toBe(2); + }); + + it("emits with source=ui, level=warn, and the failure URL in data", async () => { + mockFetch.mockRejectedValueOnce(new Error("ETIMEDOUT")); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "ui", + kind: "ui.session_broadcast_failed", + level: "warn", + }), + ); + const call = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === "ui.session_broadcast_failed", + )![0] as { data: Record }; + expect(call.data["url"]).toContain("/api/sessions/patches"); + expect(call.data["errorMessage"]).toContain("ETIMEDOUT"); + }); + + it("includes httpStatus when fetch returns non-OK response", async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + broadcaster.subscribe(vi.fn()); + await vi.advanceTimersByTimeAsync(10); + + const call = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === "ui.session_broadcast_failed", + )![0] as { data: Record }; + expect(call.data["httpStatus"]).toBe(503); + }); + }); +}); + +// ── Connection-level activity events ────────────────────────────────── +// These verify ui.terminal_* events fire at the right WS lifecycle points. +// We exercise the connection handler directly by emitting "connection" on +// the WebSocketServer and feeding a fake ws + IncomingMessage stand-in. + +class FakeWS extends EventEmitter { + readyState: 0 | 1 | 2 | 3 = WebSocket.OPEN; + bufferedAmount = 0; + ping = vi.fn(); + terminate = vi.fn(() => { + this.readyState = WebSocket.CLOSED; + }); + send = vi.fn(); +} + +class FakePipeSocket extends EventEmitter { + write = vi.fn(); + end = vi.fn(() => { + this.emit("close"); + }); + destroy = vi.fn(() => { + this.emit("close"); + }); +} + +function makeFakeRequest(opts?: { remoteAddress?: string; xff?: string }) { + return { + headers: opts?.xff ? { "x-forwarded-for": opts.xff } : {}, + socket: { remoteAddress: opts?.remoteAddress ?? "127.0.0.1" }, + }; +} + +describe("mux WebSocket connection events", () => { + beforeEach(() => { + vi.useFakeTimers(); + recordActivityEvent.mockClear(); + resetPtyMock(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + function emitConnection(opts?: Parameters[0]) { + const wss = createMuxWebSocket(); + if (!wss) { + throw new Error("mux WS server not created — node-pty unavailable"); + } + const ws = new FakeWS(); + wss.emit("connection", ws as unknown as WebSocket, makeFakeRequest(opts)); + return { wss, ws }; + } + + function findEvent(kind: string): { data: Record } | undefined { + const found = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === kind, + ); + return found?.[0] as { data: Record } | undefined; + } + + it("emits ui.terminal_connected on a new mux connection (with remoteAddr)", () => { + emitConnection({ xff: "198.51.100.5, 10.0.0.1" }); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ source: "ui", kind: "ui.terminal_connected" }), + ); + const evt = findEvent("ui.terminal_connected")!; + expect(evt.data["remoteAddr"]).toBe("198.51.100.5"); + }); + + it("emits ui.terminal_disconnected exactly once on close", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + ws.emit("close", 1000, Buffer.from("normal")); + + const calls = recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_disconnected", + ); + expect(calls.length).toBe(1); + const evt = findEvent("ui.terminal_disconnected")!; + expect(evt.data["code"]).toBe(1000); + expect(evt.data["reason"]).toBe("normal"); + }); + + it("emits ui.terminal_heartbeat_lost once on 3 missed pongs and terminates", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + // Each 15s interval sends a ping and increments missedPongs by 1. + // After 3 ticks (45s) it should hit MAX_MISSED_PONGS=3 and terminate. + vi.advanceTimersByTime(15_000); + vi.advanceTimersByTime(15_000); + vi.advanceTimersByTime(15_000); + + const calls = recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_heartbeat_lost", + ); + expect(calls.length).toBe(1); + expect(ws.terminate).toHaveBeenCalled(); + + // Issue invariant: at most one emit per state change — extra ticks must not + // produce another event. + vi.advanceTimersByTime(15_000); + expect( + recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_heartbeat_lost", + ).length, + ).toBe(1); + }); + + it("does NOT emit heartbeat_lost when pong arrives before 3 missed pings", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + vi.advanceTimersByTime(15_000); // missedPongs=1 + ws.emit("pong"); // resets to 0 + vi.advanceTimersByTime(15_000); // missedPongs=1 + vi.advanceTimersByTime(15_000); // missedPongs=2 + + expect( + recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_heartbeat_lost", + ).length, + ).toBe(0); + expect(ws.terminate).not.toHaveBeenCalled(); + }); + + it("emits ui.terminal_protocol_error on malformed client message", () => { + const { ws } = emitConnection(); + recordActivityEvent.mockClear(); + + ws.emit("message", Buffer.from("not-json{{{")); + + const calls = recordActivityEvent.mock.calls.filter( + ([e]) => (e as { kind: string }).kind === "ui.terminal_protocol_error", + ); + expect(calls.length).toBe(1); + const evt = findEvent("ui.terminal_protocol_error")!; + expect(evt.data["errorMessage"]).toBeTruthy(); + }); +}); + +describe("Windows pipe ui.terminal_pty_lost activity events", () => { + beforeEach(() => { + recordActivityEvent.mockClear(); + }); + + function framedMessage(type: number, payload: unknown): Buffer { + const body = Buffer.from(JSON.stringify(payload), "utf-8"); + const header = Buffer.alloc(5); + header.writeUInt8(type, 0); + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); + } + + function openPipe() { + const ws = new FakeWS(); + const pipe = new FakePipeSocket(); + const winPipes = new Map(); + const winPipeBuffers = new Map(); + const deps = { + connect: vi.fn(() => pipe as unknown as Socket), + resolvePipePath: vi.fn(() => "\\\\.\\pipe\\ao-pty-app-1"), + }; + + handleWindowsPipeMessage( + { id: "app-1", type: "open", projectId: "proj-1" }, + ws, + winPipes, + winPipeBuffers, + deps, + ); + pipe.emit("connect"); + recordActivityEvent.mockClear(); + ws.send.mockClear(); + + return { ws, pipe, winPipes, winPipeBuffers, deps }; + } + + function ptyLostEvents(): Array<{ data: Record; sessionId?: string }> { + return recordActivityEvent.mock.calls + .map(([e]) => e as { kind: string; data: Record; sessionId?: string }) + .filter((event) => event.kind === "ui.terminal_pty_lost"); + } + + it("emits ui.terminal_pty_lost when the PTY host pipe closes while the socket is open", () => { + const { ws, pipe } = openPipe(); + + pipe.emit("close"); + + expect(ptyLostEvents()).toEqual([ + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + transport: "windows_pipe", + reason: "pipe_closed", + }), + }), + ]); + expect(ws.send).toHaveBeenCalledWith( + JSON.stringify({ ch: "terminal", id: "app-1", type: "exited", code: 0, projectId: "proj-1" }), + ); + }); + + it("emits ui.terminal_pty_lost when the PTY host reports not alive", () => { + const { ws, pipe } = openPipe(); + + pipe.emit("data", framedMessage(0x07, { alive: false })); + pipe.emit("close"); + + const events = ptyLostEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + transport: "windows_pipe", + reason: "host_not_alive", + }), + }), + ); + expect(ws.send).toHaveBeenCalledWith( + JSON.stringify({ ch: "terminal", id: "app-1", type: "exited", code: 0, projectId: "proj-1" }), + ); + }); + + it("does not emit ui.terminal_pty_lost for an intentional client close", () => { + const { ws, winPipes, winPipeBuffers, deps } = openPipe(); + + handleWindowsPipeMessage( + { id: "app-1", type: "close", projectId: "proj-1" }, + ws, + winPipes, + winPipeBuffers, + deps, + ); + + expect(ptyLostEvents()).toEqual([]); + }); +}); + +describe("TerminalManager ui.terminal_pty_lost activity events", () => { + beforeEach(() => { + recordActivityEvent.mockClear(); + resetPtyMock(); + }); + + function ptyLostEvents(): Array<{ data: Record; sessionId?: string }> { + return recordActivityEvent.mock.calls + .map(([e]) => e as { kind: string; data: Record; sessionId?: string }) + .filter((event) => event.kind === "ui.terminal_pty_lost"); + } + + it("emits ui.terminal_pty_lost when a subscribed PTY exits and reattach fails", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const pty = ptyInstances[0]; + expect(pty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + mockPtySpawn.mockImplementationOnce(() => { + throw new Error("reattach unavailable"); + }); + + await pty!.emitExit(9); + + const events = ptyLostEvents(); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + exitCode: 9, + subscriberCount: 1, + reattachError: "reattach unavailable", + }), + }), + ); + }); + + it("emits ui.terminal_pty_lost when a subscribed PTY exits and reattach succeeds", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const pty = ptyInstances[0]; + expect(pty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + + await pty!.emitExit(9); + + const events = ptyLostEvents(); + expect(mockPtySpawn).toHaveBeenCalledTimes(2); + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + sessionId: "app-1", + data: expect.objectContaining({ + sessionId: "app-1", + exitCode: 9, + subscriberCount: 1, + reattachRecovered: true, + reattachExhausted: false, + }), + }), + ); + }); + + it("does not emit ui.terminal_pty_lost when the last subscriber already left", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const pty = ptyInstances[0]; + expect(pty).toBeDefined(); + + const unsubscribe = manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + unsubscribe(); + + await pty!.emitExit(0); + + expect(ptyLostEvents()).toEqual([]); + }); + + it("emits ui.terminal_pty_lost at most once across reattach cycles", async () => { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const firstPty = ptyInstances[0]; + expect(firstPty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + mockPtySpawn.mockImplementationOnce(() => { + throw new Error("first reattach unavailable"); + }); + await firstPty!.emitExit(7); + expect(ptyLostEvents()).toHaveLength(1); + + // A client may try to re-open the terminal after the first PTY loss. + // A second failed reattach should not produce another activity event for + // the same terminal entry. + manager.open("app-1", "proj-1", "tmux-app-1"); + const secondPty = ptyInstances[1]; + expect(secondPty).toBeDefined(); + mockPtySpawn.mockImplementationOnce(() => { + throw new Error("second reattach unavailable"); + }); + await secondPty!.emitExit(8); + + expect(ptyLostEvents()).toHaveLength(1); + }); + + it("re-arms ui.terminal_pty_lost after a successful reattach survives the grace period", async () => { + vi.useFakeTimers(); + try { + const manager = new TerminalManager("/usr/bin/tmux"); + manager.open("app-1", "proj-1", "tmux-app-1"); + const firstPty = ptyInstances[0]; + expect(firstPty).toBeDefined(); + + manager.subscribe("app-1", "proj-1", vi.fn(), vi.fn()); + await firstPty!.emitExit(7); + expect(ptyLostEvents()).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(5_000); + + const secondPty = ptyInstances[1]; + expect(secondPty).toBeDefined(); + await secondPty!.emitExit(8); + + expect(ptyLostEvents()).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("NotificationBroadcaster", () => { + let tempDir: string | null = null; + let configPath: string; + + beforeEach(() => { + vi.useFakeTimers(); + tempDir = mkdtempSync(join(tmpdir(), "ao-notification-broadcaster-")); + configPath = join(tempDir, "agent-orchestrator.yaml"); + writeFileSync( + configPath, + [ + "projects: {}", + "notifiers:", + " dashboard:", + " plugin: dashboard", + " limit: 2", + "", + ].join("\n"), + ); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + function makeEvent(id: string): OrchestratorEvent { + return { + id, + type: "session.needs_input", + priority: "action", + sessionId: "worker-1", + projectId: "demo", + timestamp: new Date("2026-05-13T12:00:00.000Z"), + message: `Event ${id}`, + data: {}, + }; + } + + function appendEvent(id: string, receivedAt: string): void { + appendDashboardNotification(configPath, makeEvent(id), undefined, { + receivedAt: new Date(receivedAt), + limit: 2, + }); + } + + function appendEventWithLimit(id: string, receivedAt: string, limit: number): void { + appendDashboardNotification(configPath, makeEvent(id), undefined, { + receivedAt: new Date(receivedAt), + limit, + }); + } + + it("sends an immediate dashboard notification snapshot", () => { + appendEvent("evt-1", "2026-05-13T12:00:01.000Z"); + const broadcaster = new NotificationBroadcaster(configPath); + const callback = vi.fn(); + + const unsubscribe = broadcaster.subscribe(callback); + + expect(callback).toHaveBeenCalledWith( + [expect.objectContaining({ event: expect.objectContaining({ id: "evt-1" }) })], + "snapshot", + 2, + ); + + unsubscribe(); + }); + + it("does not let a new subscriber suppress appends for existing subscribers", () => { + appendEvent("evt-1", "2026-05-13T12:00:01.000Z"); + const broadcaster = new NotificationBroadcaster(configPath); + const first = vi.fn(); + const second = vi.fn(); + + const unsubscribeFirst = broadcaster.subscribe(first); + appendEvent("evt-2", "2026-05-13T12:00:02.000Z"); + const unsubscribeSecond = broadcaster.subscribe(second); + + vi.advanceTimersByTime(1000); + + expect(first).toHaveBeenLastCalledWith( + [expect.objectContaining({ event: expect.objectContaining({ id: "evt-2" }) })], + "append", + 2, + ); + expect(second).toHaveBeenCalledWith( + [ + expect.objectContaining({ event: expect.objectContaining({ id: "evt-1" }) }), + expect.objectContaining({ event: expect.objectContaining({ id: "evt-2" }) }), + ], + "snapshot", + 2, + ); + + unsubscribeFirst(); + unsubscribeSecond(); + }); + + it("reloads the dashboard notification limit while polling", () => { + appendEvent("evt-1", "2026-05-13T12:00:01.000Z"); + const broadcaster = new NotificationBroadcaster(configPath); + const callback = vi.fn(); + + const unsubscribe = broadcaster.subscribe(callback); + expect(callback).toHaveBeenCalledWith(expect.any(Array), "snapshot", 2); + + writeFileSync( + configPath, + [ + "projects: {}", + "notifiers:", + " dashboard:", + " plugin: dashboard", + " limit: 3", + "", + ].join("\n"), + ); + appendEventWithLimit("evt-2", "2026-05-13T12:00:02.000Z", 3); + appendEventWithLimit("evt-3", "2026-05-13T12:00:03.000Z", 3); + + vi.advanceTimersByTime(1000); + + expect(callback).toHaveBeenLastCalledWith( + [ + expect.objectContaining({ event: expect.objectContaining({ id: "evt-2" }) }), + expect.objectContaining({ event: expect.objectContaining({ id: "evt-3" }) }), + ], + "append", + 3, + ); + + unsubscribe(); + }); }); describe("TerminalManager.open — tmux target args (regression for #1714)", () => { @@ -347,6 +1001,7 @@ describe("TerminalManager.open — re-attach skipped when tmux session is gone ( mockSpawn.mockReset(); mockPtySpawn.mockReset(); mockTmuxHasSession.mockReset(); + recordActivityEvent.mockClear(); capturedOnExit = undefined; mockSpawn.mockImplementation(() => new EventEmitter()); @@ -377,6 +1032,20 @@ describe("TerminalManager.open — re-attach skipped when tmux session is gone ( // Subscribers were notified with the original exit code. expect(exitCb).toHaveBeenCalledTimes(1); expect(exitCb).toHaveBeenCalledWith(0); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + sessionId: "ao-177", + data: expect.objectContaining({ + sessionId: "ao-177", + exitCode: 0, + reattachSkipped: true, + tmuxSessionPresent: false, + }), + }), + ); }); it("still re-attaches when has-session reports the tmux session is alive", async () => { diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index fb436cd68..4e4249342 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -9,6 +9,17 @@ import { WebSocketServer, WebSocket } from "ws"; import { spawn } from "node:child_process"; import { type Socket, connect as netConnect } from "node:net"; +import { + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + getEnvDefaults, + getDashboardNotificationStorePath, + isWindows, + loadConfig, + normalizeDashboardNotificationLimit, + recordActivityEvent, + readDashboardNotificationsFromFile, + type DashboardNotificationRecord, +} from "@aoagents/ao-core"; import { findTmux, resolveTmuxSession, @@ -16,7 +27,6 @@ import { tmuxHasSession, validateSessionId, } from "./tmux-utils.js"; -import { getEnvDefaults, isWindows } from "@aoagents/ao-core"; // These types mirror src/lib/mux-protocol.ts exactly. // tsconfig.server.json constrains rootDir to "server/", so we cannot import @@ -29,7 +39,7 @@ type ClientMessage = | { ch: "terminal"; id: string; type: "open"; projectId?: string; tmuxName?: string } | { ch: "terminal"; id: string; type: "close"; projectId?: string } | { ch: "system"; type: "ping" } - | { ch: "subscribe"; topics: "sessions"[] }; + | { ch: "subscribe"; topics: Array<"sessions" | "notifications"> }; // ── Server → Client ── type ServerMessage = @@ -39,6 +49,13 @@ type ServerMessage = | { ch: "terminal"; id: string; type: "error"; message: string; projectId?: string } | { ch: "sessions"; type: "snapshot"; sessions: SessionPatch[] } | { ch: "sessions"; type: "error"; error: string } + | { + ch: "notifications"; + type: "snapshot" | "append"; + notifications: DashboardNotificationRecord[]; + limit: number; + } + | { ch: "notifications"; type: "error"; error: string } | { ch: "system"; type: "pong" } | { ch: "system"; type: "error"; message: string }; @@ -63,6 +80,9 @@ export class SessionBroadcaster { private errorSubscribers = new Set<(error: string) => void>(); private intervalId: ReturnType | null = null; private polling = false; + // Tracks the last fetch outcome so we only emit ui.session_broadcast_failed on + // the healthy → failing transition (not every 3s during an outage). + private lastFetchOk = true; private readonly baseUrl: string; private readonly authHeader: string | null; @@ -164,18 +184,197 @@ export class SessionBroadcaster { if (!res.ok) { const msg = `Session fetch failed: HTTP ${res.status}`; console.warn(`[SessionBroadcaster] ${msg}`); + this.recordFetchFailure(msg, { httpStatus: res.status }); return { sessions: null, error: msg }; } const data = (await res.json()) as { sessions?: SessionPatch[] }; + this.lastFetchOk = true; return { sessions: data.sessions ?? null, error: null }; } catch (err) { clearTimeout(timeoutId); const msg = err instanceof Error ? err.message : String(err); console.warn("[SessionBroadcaster] fetchSnapshot error:", msg); + this.recordFetchFailure(msg); return { sessions: null, error: msg }; } } + /** + * Emit ui.session_broadcast_failed once per healthy→failing transition. + * The broadcaster polls every 3s; emitting on every failure during a long + * outage would flood the events table (~20/min). Recovery resets the flag. + */ + private recordFetchFailure(message: string, extra?: Record): void { + if (!this.lastFetchOk) return; + this.lastFetchOk = false; + recordActivityEvent({ + source: "ui", + kind: "ui.session_broadcast_failed", + level: "warn", + summary: `session broadcaster fetch failed: ${message}`, + data: { + url: `${this.baseUrl}/api/sessions/patches`, + errorMessage: message, + ...extra, + }, + }); + } + + private disconnect(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } +} + +function notificationKey(record: DashboardNotificationRecord): string { + return `${record.id}:${record.receivedAt}`; +} + +function readDashboardLimit(configPath: string | undefined): number { + if (!configPath) return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + try { + const config = loadConfig(configPath); + const dashboardConfig = config.notifiers?.["dashboard"]; + return normalizeDashboardNotificationLimit(dashboardConfig?.["limit"]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[NotificationBroadcaster] Could not read dashboard notifier limit:", message); + return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + } +} + +/** + * Polls the dashboard notification JSONL store and broadcasts changes to mux + * subscribers. The store is config-scoped and survives dashboard reloads. + */ +export class NotificationBroadcaster { + private subscribers = new Set< + ( + notifications: DashboardNotificationRecord[], + type: "snapshot" | "append", + limit: number, + ) => void + >(); + private errorSubscribers = new Set<(error: string) => void>(); + private intervalId: ReturnType | null = null; + private lastRecords: DashboardNotificationRecord[] = []; + private readonly configPath: string | undefined; + private readonly storePath: string | null; + + constructor(configPath = process.env["AO_CONFIG_PATH"]) { + this.configPath = configPath; + this.storePath = configPath ? getDashboardNotificationStorePath(configPath) : null; + } + + subscribe( + callback: ( + notifications: DashboardNotificationRecord[], + type: "snapshot" | "append", + limit: number, + ) => void, + onError?: (error: string) => void, + ): () => void { + const wasEmpty = this.subscribers.size === 0; + this.subscribers.add(callback); + if (onError) this.errorSubscribers.add(onError); + + const snapshot = this.fetchSnapshot(); + if (wasEmpty) { + this.lastRecords = snapshot.notifications; + } + try { + callback(snapshot.notifications, "snapshot", snapshot.limit); + } catch { + // Isolate subscriber errors so one bad socket does not break others. + } + + if (snapshot.error && onError) { + try { + onError(snapshot.error); + } catch { + // Isolate subscriber errors. + } + } + + if (wasEmpty) { + this.intervalId = setInterval(() => { + const result = this.fetchSnapshot(); + if (result.error) { + this.broadcastError(result.error); + return; + } + + const previousKeys = new Set(this.lastRecords.map(notificationKey)); + const appended = result.notifications.filter( + (record) => !previousKeys.has(notificationKey(record)), + ); + const trimmed = result.notifications.length < this.lastRecords.length; + this.lastRecords = result.notifications; + + if (appended.length > 0 && !trimmed) { + this.broadcast(appended, "append", result.limit); + } else if (appended.length > 0 || trimmed) { + this.broadcast(result.notifications, "snapshot", result.limit); + } + }, 1000); + } + + return () => { + this.subscribers.delete(callback); + if (onError) this.errorSubscribers.delete(onError); + if (this.subscribers.size === 0) { + this.disconnect(); + } + }; + } + + private fetchSnapshot(): { + notifications: DashboardNotificationRecord[]; + error: string | null; + limit: number; + } { + const limit = readDashboardLimit(this.configPath); + if (!this.storePath) return { notifications: [], error: null, limit }; + + try { + return { + notifications: readDashboardNotificationsFromFile(this.storePath, limit), + error: null, + limit, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[NotificationBroadcaster] fetchSnapshot error:", message); + return { notifications: [], error: message, limit }; + } + } + + private broadcast( + notifications: DashboardNotificationRecord[], + type: "snapshot" | "append", + limit: number, + ): void { + for (const callback of this.subscribers) { + try { + callback(notifications, type, limit); + } catch (err) { + console.error("[MuxServer] Notification broadcast subscriber threw:", err); + } + } + } + + private broadcastError(error: string): void { + for (const callback of this.errorSubscribers) { + try { + callback(error); + } catch (err) { + console.error("[MuxServer] Notification error subscriber threw:", err); + } + } + } + private disconnect(): void { if (this.intervalId !== null) { clearInterval(this.intervalId); @@ -205,6 +404,7 @@ interface ManagedTerminal { buffer: string[]; bufferBytes: number; reattachAttempts: number; + ptyLostEmitted: boolean; /** * Pending grace-period timer that resets reattachAttempts when the * currently-attached PTY survives REATTACH_RESET_GRACE_MS. Tracked so @@ -282,6 +482,7 @@ export class TerminalManager { buffer: [], bufferBytes: 0, reattachAttempts: 0, + ptyLostEmitted: false, }; this.terminals.set(key, terminal); } @@ -352,6 +553,7 @@ export class TerminalManager { terminal.resetTimer = undefined; if (terminal.pty === pty) { terminal.reattachAttempts = 0; + terminal.ptyLostEmitted = false; } }, REATTACH_RESET_GRACE_MS); terminal.resetTimer.unref(); @@ -389,6 +591,7 @@ export class TerminalManager { pty.onExit(async ({ exitCode }) => { console.log(`[MuxServer] PTY exited for ${id} with code ${exitCode}`); terminal.pty = null; + let reattachError: string | undefined; // Skip the re-attach loop entirely when the underlying tmux session is // gone (e.g. user pressed Ctrl-C in the pane and the launch command @@ -398,15 +601,33 @@ export class TerminalManager { // clean user-initiated termination — see issue #1756. The // MAX_REATTACH_ATTEMPTS bound from #1640 still covers tmux server // hiccups where the session does still exist. - if ( - terminal.subscribers.size > 0 && - !(await tmuxHasSession(this.TMUX, tmuxSessionId)) - ) { + if (terminal.subscribers.size > 0 && !(await tmuxHasSession(this.TMUX, tmuxSessionId))) { console.log(`[MuxServer] tmux session ${tmuxSessionId} is gone, not re-attaching`); if (terminal.resetTimer) { clearTimeout(terminal.resetTimer); terminal.resetTimer = undefined; } + if (!terminal.ptyLostEmitted) { + terminal.ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: `terminal PTY exited (code ${exitCode}) — tmux session gone`, + data: { + sessionId: id, + exitCode, + reattachAttempts: terminal.reattachAttempts, + maxReattachAttempts: MAX_REATTACH_ATTEMPTS, + reattachExhausted: false, + reattachSkipped: true, + tmuxSessionPresent: false, + subscriberCount: terminal.subscribers.size, + }, + }); + } for (const cb of terminal.exitCallbacks) { cb(exitCode); } @@ -429,14 +650,63 @@ export class TerminalManager { ); try { this.open(id, projectId, tmuxSessionId); + if (!terminal.ptyLostEmitted) { + terminal.ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: `terminal PTY exited (code ${exitCode}) — reattached`, + data: { + sessionId: id, + exitCode, + reattachAttempts: terminal.reattachAttempts, + maxReattachAttempts: MAX_REATTACH_ATTEMPTS, + reattachExhausted: false, + reattachRecovered: true, + subscriberCount: terminal.subscribers.size, + }, + }); + } return; // re-attached — don't notify exit } catch (err) { + reattachError = err instanceof Error ? err.message : String(err); console.error(`[MuxServer] Failed to re-attach ${id}:`, err); } } else if (terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS) { console.error(`[MuxServer] Max re-attach attempts reached for ${id}, giving up`); } + // PTY actually died (vs user closed browser): only emit when subscribers + // are still attached — otherwise the exit is just normal cleanup. + // Keep this event one-shot for the terminal entry. Clients may re-open + // the same terminal after a failed reattach; repeated PTY exits should + // not flood the activity log for the same loss condition. + if (terminal.subscribers.size > 0 && !terminal.ptyLostEmitted) { + terminal.ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: `terminal PTY exited (code ${exitCode})${ + terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS ? " — reattach exhausted" : "" + }`, + data: { + sessionId: id, + exitCode, + reattachAttempts: terminal.reattachAttempts, + maxReattachAttempts: MAX_REATTACH_ATTEMPTS, + reattachExhausted: terminal.reattachAttempts >= MAX_REATTACH_ATTEMPTS, + subscriberCount: terminal.subscribers.size, + ...(reattachError ? { reattachError } : {}), + }, + }); + } + // Notify subscribers that the terminal has exited (re-attach failed or no subscribers) for (const cb of terminal.exitCallbacks) { cb(exitCode); @@ -521,6 +791,8 @@ export class TerminalManager { // ── Windows Pipe Relay (extracted for testability) ── +const intentionalWinPipeCloses = new WeakSet(); + /** Minimal WebSocket-like interface for the pipe relay handler */ export interface WsSink { send(data: string): void; @@ -592,8 +864,36 @@ export function handleWindowsPipeMessage( const pipeSocket = deps.connect(pipePath); winPipes.set(pipeKey, pipeSocket); winPipeBuffers.set(pipeKey, Buffer.alloc(0)); + let ptyLostEmitted = false; + const recordWindowsPtyLost = ( + reason: "pipe_closed" | "host_not_alive" | "pipe_error", + extra?: Record, + ): void => { + if (ptyLostEmitted || ws.readyState !== WS_OPEN) return; + ptyLostEmitted = true; + recordActivityEvent({ + projectId, + sessionId: id, + source: "ui", + kind: "ui.terminal_pty_lost", + level: "warn", + summary: + reason === "host_not_alive" + ? `terminal PTY host reported not alive for ${id}` + : reason === "pipe_error" + ? `terminal PTY host pipe errored for ${id}` + : `terminal PTY host pipe closed for ${id}`, + data: { + sessionId: id, + transport: "windows_pipe", + reason, + ...extra, + }, + }); + }; pipeSocket.on("error", (err) => { + recordWindowsPtyLost("pipe_error", { errorMessage: err.message }); winPipes.delete(pipeKey); winPipeBuffers.delete(pipeKey); pipeSocket.destroy(); @@ -643,9 +943,8 @@ export function handleWindowsPipeMessage( try { const status = JSON.parse(payload.toString("utf-8")) as { alive: boolean }; if (!status.alive && ws.readyState === WS_OPEN) { - ws.send( - JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo }), - ); + recordWindowsPtyLost("host_not_alive"); + ws.send(JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo })); } } catch { /* ignore parse errors */ @@ -657,7 +956,11 @@ export function handleWindowsPipeMessage( pipeSocket.on("close", () => { winPipes.delete(pipeKey); winPipeBuffers.delete(pipeKey); + const intentionalClose = intentionalWinPipeCloses.delete(pipeSocket); if (ws.readyState === WS_OPEN) { + if (!intentionalClose) { + recordWindowsPtyLost("pipe_closed"); + } ws.send(JSON.stringify({ ch: "terminal", id, type: "exited", code: 0, ...echo })); } }); @@ -684,6 +987,7 @@ export function handleWindowsPipeMessage( } else if (type === "close") { const pipeSocket = winPipes.get(pipeKey); if (pipeSocket) { + intentionalWinPipeCloses.add(pipeSocket); pipeSocket.end(); winPipes.delete(pipeKey); winPipeBuffers.delete(pipeKey); @@ -709,19 +1013,39 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | const nextPort = process.env.PORT || "3000"; const broadcaster = new SessionBroadcaster(nextPort); + const notificationBroadcaster = new NotificationBroadcaster(); const wss = new WebSocketServer({ noServer: true }); - wss.on("connection", (ws) => { + wss.on("connection", (ws, request) => { console.log("[MuxServer] New mux connection"); + const connectedAt = Date.now(); + // Best-effort remote addr — proxy headers if present, else socket peer. + const xff = request?.headers["x-forwarded-for"]; + const xffStr = Array.isArray(xff) ? xff[0] : xff; + const remoteAddr = + (typeof xffStr === "string" ? xffStr.split(",")[0]?.trim() : undefined) ?? + request?.socket?.remoteAddress ?? + undefined; + + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_connected", + level: "info", + summary: "mux WebSocket connection opened", + data: { remoteAddr }, + }); + const subscriptions = new Map void>(); // Windows: named pipe sockets keyed by session ID const winPipes = new Map>(); // Windows: framing buffers keyed by session ID const winPipeBuffers = new Map(); let sessionUnsubscribe: (() => void) | null = null; + let notificationUnsubscribe: (() => void) | null = null; let missedPongs = 0; + let heartbeatLostEmitted = false; const MAX_MISSED_PONGS = 3; // Heartbeat: send native WebSocket ping every 15s. @@ -734,6 +1058,22 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | missedPongs += 1; if (missedPongs >= MAX_MISSED_PONGS) { console.log("[MuxServer] Too many missed pongs, terminating connection"); + if (!heartbeatLostEmitted) { + heartbeatLostEmitted = true; + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_heartbeat_lost", + level: "warn", + summary: `mux WebSocket heartbeat lost (${missedPongs} missed pongs)`, + data: { + missedPongs, + maxMissedPongs: MAX_MISSED_PONGS, + connectionAgeMs: Date.now() - connectedAt, + remoteAddr, + subscriberCount: subscriptions.size, + }, + }); + } ws.terminate(); } } @@ -765,7 +1105,14 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | if (type === "open") { if (isWindows()) { handleWindowsPipeMessage( - msg as { id: string; type: string; projectId?: string; data?: string; cols?: number; rows?: number }, + msg as { + id: string; + type: string; + projectId?: string; + data?: string; + cols?: number; + rows?: number; + }, ws, winPipes, winPipeBuffers, @@ -847,7 +1194,13 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | } else if (type === "resize" && "cols" in msg && "rows" in msg) { if (isWindows()) { handleWindowsPipeMessage( - msg as { id: string; type: string; projectId?: string; cols: number; rows: number }, + msg as { + id: string; + type: string; + projectId?: string; + cols: number; + rows: number; + }, ws, winPipes, winPipeBuffers, @@ -859,7 +1212,14 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | } else if (type === "close") { if (isWindows()) { handleWindowsPipeMessage( - msg as { id: string; type: string; projectId?: string; data?: string; cols?: number; rows?: number }, + msg as { + id: string; + type: string; + projectId?: string; + data?: string; + cols?: number; + rows?: number; + }, ws, winPipes, winPipeBuffers, @@ -906,9 +1266,43 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | }, ); } + if (msg.topics.includes("notifications") && !notificationUnsubscribe) { + notificationUnsubscribe = notificationBroadcaster.subscribe( + (notifications, type, limit) => { + if (ws.readyState !== WebSocket.OPEN) return; + if (ws.bufferedAmount > WS_BUFFER_HIGH_WATERMARK) { + console.warn("[MuxServer] Skipping notification update — socket backpressured"); + return; + } + const msg: ServerMessage = { + ch: "notifications", + type, + notifications, + limit, + }; + ws.send(JSON.stringify(msg)); + }, + (error) => { + if (ws.readyState !== WebSocket.OPEN) return; + const errMsg: ServerMessage = { ch: "notifications", type: "error", error }; + ws.send(JSON.stringify(errMsg)); + }, + ); + } } } catch (err) { console.error("[MuxServer] Failed to parse message:", err); + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_protocol_error", + level: "warn", + summary: "invalid mux client message — parse failed", + data: { + errorMessage: err instanceof Error ? err.message : String(err), + remoteAddr, + subscriberCount: subscriptions.size, + }, + }); const errorMsg: ServerMessage = { ch: "system", type: "error", @@ -923,11 +1317,27 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | /** * Handle connection close */ - ws.on("close", () => { + ws.on("close", (code, reason) => { console.log("[MuxServer] Mux connection closed"); + recordActivityEvent({ + source: "ui", + kind: "ui.terminal_disconnected", + level: "info", + summary: "mux WebSocket connection closed", + data: { + code, + reason: reason?.toString("utf8") || undefined, + connectionAgeMs: Date.now() - connectedAt, + subscriberCount: subscriptions.size, + heartbeatLost: heartbeatLostEmitted, + remoteAddr, + }, + }); clearInterval(heartbeatInterval); sessionUnsubscribe?.(); sessionUnsubscribe = null; + notificationUnsubscribe?.(); + notificationUnsubscribe = null; for (const unsub of subscriptions.values()) { unsub(); } diff --git a/packages/web/src/__tests__/activity-events-projects.test.ts b/packages/web/src/__tests__/activity-events-projects.test.ts new file mode 100644 index 000000000..2aad007da --- /dev/null +++ b/packages/web/src/__tests__/activity-events-projects.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { NextRequest } from "next/server"; +import { recordActivityEvent, registerProjectInGlobalConfig } from "@aoagents/ao-core"; + +vi.mock("@aoagents/ao-core", async () => { + const actual = await vi.importActual("@aoagents/ao-core"); + return { + ...(actual as Record), + recordActivityEvent: vi.fn(), + }; +}); + +const invalidatePortfolioServicesCache = vi.fn(); +const getServices = vi.fn(); + +vi.mock("@/lib/services", () => ({ + invalidatePortfolioServicesCache, + getServices, +})); + +function makeRequest(method: string, url: string, body?: Record): NextRequest { + return new NextRequest(url, { + method, + body: body ? JSON.stringify(body) : undefined, + headers: body ? { "Content-Type": "application/json" } : undefined, + }); +} + +const recorded = vi.mocked(recordActivityEvent); + +describe("Activity events — project mutation routes", () => { + let oldGlobalConfig: string | undefined; + let oldConfigPath: string | undefined; + let oldHome: string | undefined; + let tempRoot: string; + let configPath: string; + + beforeEach(() => { + vi.resetModules(); + recorded.mockClear(); + invalidatePortfolioServicesCache.mockReset(); + getServices.mockReset(); + getServices.mockResolvedValue({ + registry: { get: vi.fn().mockReturnValue(null) }, + sessionManager: { + list: vi.fn().mockResolvedValue([]), + kill: vi.fn().mockResolvedValue(undefined), + }, + }); + oldGlobalConfig = process.env["AO_GLOBAL_CONFIG"]; + oldConfigPath = process.env["AO_CONFIG_PATH"]; + oldHome = process.env["HOME"]; + tempRoot = mkdtempSync(path.join(tmpdir(), "ao-activity-projects-")); + configPath = path.join(tempRoot, "config.yaml"); + process.env["AO_GLOBAL_CONFIG"] = configPath; + process.env["AO_CONFIG_PATH"] = configPath; + process.env["HOME"] = tempRoot; + }); + + afterEach(() => { + if (oldGlobalConfig === undefined) delete process.env["AO_GLOBAL_CONFIG"]; + else process.env["AO_GLOBAL_CONFIG"] = oldGlobalConfig; + if (oldConfigPath === undefined) delete process.env["AO_CONFIG_PATH"]; + else process.env["AO_CONFIG_PATH"] = oldConfigPath; + if (oldHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = oldHome; + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("POST /api/projects emits api.project_added on success", async () => { + const repoDir = path.join(tempRoot, "demo-add"); + mkdirSync(repoDir, { recursive: true }); + execSync("git init -q", { cwd: repoDir }); + + const { POST } = await import("@/app/api/projects/route"); + const res = await POST( + makeRequest("POST", "http://localhost:3000/api/projects", { + path: repoDir, + projectId: "demo-add", + name: "Demo Add", + }), + ); + + expect(res.status).toBe(201); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.project_added", + }), + ); + }); + + it("PATCH /api/projects/:id emits api.project_updated with changed keys (not values)", async () => { + const repoDir = path.join(tempRoot, "demo-patch"); + mkdirSync(repoDir, { recursive: true }); + const effectiveId = registerProjectInGlobalConfig("demo-patch", "Demo Patch", repoDir); + + const { PATCH } = await import("@/app/api/projects/[id]/route"); + const res = await PATCH( + makeRequest("PATCH", `http://localhost:3000/api/projects/${effectiveId}`, { + agent: "codex", + runtime: "tmux", + someUnknownField: "do-not-record", + }), + { params: Promise.resolve({ id: effectiveId }) }, + ); + + expect(res.status).toBe(200); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.project_updated", + projectId: effectiveId, + data: expect.objectContaining({ + changedKeys: expect.arrayContaining(["agent", "runtime"]), + }), + }), + ); + + // Ensure no value content (e.g. "codex", "tmux") leaked into the event payload + const calls = recorded.mock.calls.filter( + (c) => (c[0] as { kind: string }).kind === "api.project_updated", + ); + expect(calls[0]?.[0]).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + changedKeys: ["agent", "runtime"], + }), + }), + ); + for (const [event] of calls) { + const json = JSON.stringify(event); + expect(json).not.toContain("codex"); + expect(json).not.toContain('"tmux"'); + expect(json).not.toContain("someUnknownField"); + expect(json).not.toContain("do-not-record"); + } + }); + + it("DELETE /api/projects/:id emits api.project_removed on success", async () => { + const repoDir = path.join(tempRoot, "demo-delete"); + mkdirSync(repoDir, { recursive: true }); + const effectiveId = registerProjectInGlobalConfig("demo-delete", "Demo Delete", repoDir); + + const { DELETE } = await import("@/app/api/projects/[id]/route"); + const res = await DELETE( + makeRequest("DELETE", `http://localhost:3000/api/projects/${effectiveId}`), + { params: Promise.resolve({ id: effectiveId }) }, + ); + + expect(res.status).toBe(200); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.project_removed", + projectId: effectiveId, + }), + ); + }); +}); diff --git a/packages/web/src/__tests__/activity-events-routes.test.ts b/packages/web/src/__tests__/activity-events-routes.test.ts new file mode 100644 index 000000000..fbd2b474c --- /dev/null +++ b/packages/web/src/__tests__/activity-events-routes.test.ts @@ -0,0 +1,498 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { + SessionNotFoundError, + SessionNotRestorableError, + WorkspaceMissingError, + recordActivityEvent, + createInitialCanonicalLifecycle, + createActivitySignal, + type Session, + type SessionManager, + type OrchestratorConfig, + type PluginRegistry, + type SCM, +} from "@aoagents/ao-core"; + +// Partial mock so we replace recordActivityEvent but keep types/helpers +vi.mock("@aoagents/ao-core", async () => { + const actual = await vi.importActual("@aoagents/ao-core"); + return { + ...(actual as Record), + recordActivityEvent: vi.fn(), + }; +}); + +vi.mock("@/lib/observability", async () => { + const actual = await vi.importActual("@/lib/observability"); + return { + ...(actual as Record), + recordApiObservation: vi.fn(), + }; +}); + +function makeSession(overrides: Partial & { id: string }): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date()); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = lifecycle.session.lastTransitionAt; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + return { + projectId: "my-app", + status: "working", + activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), + lifecycle, + branch: null, + issueId: null, + pr: null, + workspacePath: null, + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +const baseSessions: Session[] = [ + makeSession({ id: "backend-3" }), + makeSession({ + id: "backend-7", + pr: { + number: 432, + url: "https://github.com/acme/my-app/pull/432", + title: "feat: health check", + owner: "acme", + repo: "my-app", + branch: "feat/health-check", + baseBranch: "main", + isDraft: false, + }, + }), + makeSession({ id: "frontend-1", status: "killed", activity: "exited" }), +]; + +const mockSessionManager: SessionManager = { + list: vi.fn(async () => baseSessions), + listCached: vi.fn(async () => baseSessions), + invalidateCache: vi.fn(), + get: vi.fn(async (id: string) => baseSessions.find((s) => s.id === id) ?? null), + spawn: vi.fn(async (cfg) => + makeSession({ + id: `session-${Date.now()}`, + projectId: cfg.projectId, + issueId: cfg.issueId ?? null, + status: "spawning", + }), + ), + kill: vi.fn(async (id: string) => { + if (!baseSessions.find((s) => s.id === id)) { + throw new SessionNotFoundError(id); + } + }), + send: vi.fn(async (id: string) => { + if (!baseSessions.find((s) => s.id === id)) { + throw new SessionNotFoundError(id); + } + }), + cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), + spawnOrchestrator: vi.fn(async () => + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ), + relaunchOrchestrator: vi.fn(async () => + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ), + ensureOrchestrator: vi.fn(), + remap: vi.fn(async () => "ses_mock"), + restore: vi.fn(async (id: string) => { + const session = baseSessions.find((s) => s.id === id); + if (!session) throw new SessionNotFoundError(id); + return { ...session, status: "spawning" as const, activity: "active" as const }; + }), +}; + +const mockSCM: SCM = { + name: "github", + detectPR: vi.fn(async () => null), + getPRState: vi.fn(async () => "open" as const), + mergePR: vi.fn(async () => {}), + closePR: vi.fn(async () => {}), + getCIChecks: vi.fn(async () => []), + getCISummary: vi.fn(async () => "passing" as const), + getReviews: vi.fn(async () => []), + getReviewDecision: vi.fn(async () => "approved" as const), + getPendingComments: vi.fn(async () => []), + getAutomatedComments: vi.fn(async () => []), + getMergeability: vi.fn(async () => ({ + mergeable: true, + ciPassing: true, + approved: true, + noConflicts: true, + blockers: [], + })), +}; + +const mockRegistry: PluginRegistry = { + register: vi.fn(), + get: vi.fn(() => mockSCM) as PluginRegistry["get"], + list: vi.fn(() => []), + loadBuiltins: vi.fn(async () => {}), + loadFromConfig: vi.fn(async () => {}), +}; + +const mockConfig: OrchestratorConfig = { + configPath: "/tmp/ao-test/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] }, + projects: { + "my-app": { + name: "My App", + repo: "acme/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "my-app", + scm: { plugin: "github" }, + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, +}; + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + config: mockConfig, + registry: mockRegistry, + sessionManager: mockSessionManager, + })), + getVerifyIssues: vi.fn(async () => []), + getSCM: vi.fn(() => mockSCM), + invalidatePortfolioServicesCache: vi.fn(), +})); + +import { getServices } from "@/lib/services"; +import { recordApiObservation } from "@/lib/observability"; +import { POST as spawnPOST } from "@/app/api/spawn/route"; +import { POST as killPOST } from "@/app/api/sessions/[id]/kill/route"; +import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; +import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; +import { POST as restorePOST } from "@/app/api/sessions/[id]/restore/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; +import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route"; + +function makeRequest(url: string, init?: RequestInit): NextRequest { + return new NextRequest( + new URL(url, "http://localhost:3000"), + init as ConstructorParameters[1], + ); +} + +const recorded = vi.mocked(recordActivityEvent); + +beforeEach(() => { + recorded.mockClear(); + vi.mocked(recordApiObservation).mockClear(); + vi.mocked(getServices).mockClear(); +}); + +describe("API mutation routes emit activity events (api source)", () => { + describe("MUST emits — session mutations", () => { + it("POST /api/spawn emits api.session_spawn_requested on success", async () => { + const req = makeRequest("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", issueId: "INT-100" }), + headers: { "Content-Type": "application/json" }, + }); + await spawnPOST(req); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_spawn_requested", + projectId: "my-app", + }), + ); + }); + + it("POST /api/sessions/:id/kill emits api.session_kill_requested on success", async () => { + const req = makeRequest("/api/sessions/backend-3/kill", { method: "POST" }); + await killPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_kill_requested", + sessionId: "backend-3", + }), + ); + }); + + it("POST /api/sessions/:id/send emits api.session_message_sent with messageLength", async () => { + const req = makeRequest("/api/sessions/backend-3/send", { + method: "POST", + body: JSON.stringify({ message: "Fix the tests" }), + headers: { "Content-Type": "application/json" }, + }); + await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_message_sent", + sessionId: "backend-3", + data: expect.objectContaining({ messageLength: "Fix the tests".length }), + }), + ); + }); + + it("POST /api/sessions/:id/send does NOT include the raw message in data", async () => { + const secret = "very-secret-PII content"; + const req = makeRequest("/api/sessions/backend-3/send", { + method: "POST", + body: JSON.stringify({ message: secret }), + headers: { "Content-Type": "application/json" }, + }); + await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + const calls = recorded.mock.calls.filter( + (c) => (c[0] as { kind: string }).kind === "api.session_message_sent", + ); + expect(calls.length).toBeGreaterThan(0); + for (const [event] of calls) { + const json = JSON.stringify(event); + expect(json).not.toContain(secret); + } + }); + + it("POST /api/sessions/:id/message emits api.session_message_sent with messageLength", async () => { + const req = makeRequest("/api/sessions/backend-3/message", { + method: "POST", + body: JSON.stringify({ message: "Hi" }), + headers: { "Content-Type": "application/json" }, + }); + await messagePOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_message_sent", + sessionId: "backend-3", + data: expect.objectContaining({ messageLength: 2 }), + }), + ); + }); + + it("POST /api/sessions/:id/restore emits api.session_restore_requested on success", async () => { + const req = makeRequest("/api/sessions/frontend-1/restore", { method: "POST" }); + await restorePOST(req, { params: Promise.resolve({ id: "frontend-1" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_restore_requested", + sessionId: "frontend-1", + }), + ); + }); + }); + + describe("MUST emits — orchestrator + PR mutations", () => { + it("POST /api/orchestrators emits api.orchestrator_spawn_requested on success", async () => { + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app" }), + headers: { "Content-Type": "application/json" }, + }); + await orchestratorsPOST(req); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.orchestrator_spawn_requested", + projectId: "my-app", + }), + ); + }); + + it("POST /api/prs/:id/merge emits api.pr_merge_requested on success", async () => { + const req = makeRequest("/api/prs/432/merge", { method: "POST" }); + await mergePOST(req, { params: Promise.resolve({ id: "432" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.pr_merge_requested", + data: expect.objectContaining({ prNumber: 432 }), + }), + ); + }); + }); + + describe("SHOULD emits — failure paths", () => { + it("POST /api/spawn emits api.session_spawn_rejected for unknown project", async () => { + const req = makeRequest("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId: "unknown-app" }), + headers: { "Content-Type": "application/json" }, + }); + await spawnPOST(req); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_spawn_rejected", + projectId: "unknown-app", + }), + ); + }); + + it("POST /api/spawn does not emit api.session_spawn_failed when core spawn throws", async () => { + (mockSessionManager.spawn as ReturnType).mockRejectedValueOnce( + new Error("runtime failed"), + ); + const req = makeRequest("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", issueId: "INT-101" }), + headers: { "Content-Type": "application/json" }, + }); + const res = await spawnPOST(req); + + expect(res.status).toBe(500); + expect( + recorded.mock.calls.some( + ([event]) => (event as { kind: string }).kind === "api.session_spawn_failed", + ), + ).toBe(false); + }); + + it.each([ + ["spawn", false, mockSessionManager.spawnOrchestrator], + ["clean relaunch", true, mockSessionManager.relaunchOrchestrator], + ])( + "POST /api/orchestrators does not emit api.orchestrator_spawn_failed when core %s throws", + async (_name, clean, method) => { + (method as ReturnType).mockRejectedValueOnce(new Error("runtime failed")); + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean }), + headers: { "Content-Type": "application/json" }, + }); + const res = await orchestratorsPOST(req); + + expect(res.status).toBe(500); + expect( + recorded.mock.calls.some( + ([event]) => (event as { kind: string }).kind === "api.orchestrator_spawn_failed", + ), + ).toBe(false); + }, + ); + + it("POST /api/sessions/:id/send emits api.session_message_failed on unexpected error", async () => { + (mockSessionManager.send as ReturnType).mockRejectedValueOnce( + new Error("write failed"), + ); + const req = makeRequest("/api/sessions/backend-3/send", { + method: "POST", + body: JSON.stringify({ message: "hi" }), + headers: { "Content-Type": "application/json" }, + }); + await sendPOST(req, { params: Promise.resolve({ id: "backend-3" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_message_failed", + sessionId: "backend-3", + data: expect.objectContaining({ messageLength: 2 }), + }), + ); + }); + + it.each([ + ["non-restorable session", new SessionNotRestorableError("my-app-123", "still working"), 409], + ["missing workspace", new WorkspaceMissingError("/tmp/missing-workspace"), 422], + ["unexpected restore error", new Error("restore failed"), 500], + ])( + "POST /api/sessions/:id/restore emits attributed api.session_restore_failed for %s", + async (_name, error, statusCode) => { + (mockSessionManager.restore as ReturnType).mockRejectedValueOnce(error); + const req = makeRequest("/api/sessions/my-app-123/restore", { method: "POST" }); + const res = await restorePOST(req, { params: Promise.resolve({ id: "my-app-123" }) }); + + expect(res.status).toBe(statusCode); + expect(vi.mocked(getServices)).toHaveBeenCalledTimes(1); + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.session_restore_failed", + projectId: "my-app", + sessionId: "my-app-123", + data: expect.objectContaining({ statusCode }), + }), + ); + }, + ); + + it("POST /api/prs/:id/merge emits api.pr_merge_rejected for non-mergeable PR", async () => { + (mockSCM.getMergeability as ReturnType).mockResolvedValueOnce({ + mergeable: false, + ciPassing: false, + approved: false, + noConflicts: true, + blockers: ["CI checks failing"], + }); + const req = makeRequest("/api/prs/432/merge", { method: "POST" }); + await mergePOST(req, { params: Promise.resolve({ id: "432" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.pr_merge_rejected", + data: expect.objectContaining({ prNumber: 432 }), + }), + ); + }); + + it("POST /api/prs/:id/merge emits api.pr_merge_failed when mergePR throws", async () => { + (mockSCM.mergePR as ReturnType).mockRejectedValueOnce(new Error("github 500")); + const req = makeRequest("/api/prs/432/merge", { method: "POST" }); + await mergePOST(req, { params: Promise.resolve({ id: "432" }) }); + + expect(recorded).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.pr_merge_failed", + projectId: "my-app", + sessionId: "backend-7", + data: expect.objectContaining({ prNumber: 432, reason: "github 500" }), + }), + ); + expect(recordApiObservation).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: "failure", + statusCode: 500, + projectId: "my-app", + sessionId: "backend-7", + data: expect.objectContaining({ prNumber: 432 }), + }), + ); + }); + }); +}); diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 6cfb82631..7a306843e 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -129,6 +129,7 @@ const mockSessionManager: SessionManager = { cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), spawnOrchestrator: vi.fn(), ensureOrchestrator: vi.fn(), + relaunchOrchestrator: vi.fn(), remap: vi.fn(async () => "ses_mock"), restore: vi.fn(async (id: string) => { const session = testSessions.find((s) => s.id === id); @@ -232,7 +233,7 @@ import { GET as sessionDetailGET, PATCH as sessionDetailPATCH, } from "@/app/api/sessions/[id]/route"; -import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -673,29 +674,18 @@ describe("API Routes", () => { const enrichSpy = vi .spyOn(serialize, "enrichSessionPR") - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); expect(res.status).toBe(200); - expect(enrichSpy).toHaveBeenCalledTimes(3); + expect(enrichSpy).toHaveBeenCalledTimes(2); expect(enrichSpy.mock.calls[0]).toEqual([ expect.objectContaining({ id: "worker-live" }), - expect.anything(), - sessionsWithPRs[0]!.pr, ]); expect(enrichSpy.mock.calls[1]).toEqual([ expect.objectContaining({ id: "worker-killed" }), - expect.anything(), - sessionsWithPRs[1]!.pr, - { cacheOnly: true }, - ]); - expect(enrichSpy.mock.calls[2]).toEqual([ - expect.objectContaining({ id: "worker-killed" }), - expect.anything(), - sessionsWithPRs[1]!.pr, ]); metadataSpy.mockRestore(); @@ -750,8 +740,6 @@ describe("API Routes", () => { expect(enrichSpy).toHaveBeenCalledTimes(1); expect(enrichSpy.mock.calls[0]).toEqual([ expect.objectContaining({ id: "worker-open-pr" }), - expect.anything(), - sessionWithOpenPR[0]!.pr, ]); metadataSpy.mockRestore(); @@ -1191,53 +1179,49 @@ describe("API Routes", () => { expect(data.recovery).toBe("reuse-or-recreate-workspace"); expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"'); }); - }); - describe("GET /api/orchestrators", () => { - it("returns orchestrators for a project", async () => { - const orchestrator = makeSession({ - id: "my-app-orchestrator", - projectId: "my-app", - metadata: { role: "orchestrator" }, + it("calls relaunchOrchestrator instead of spawnOrchestrator when clean is true", async () => { + (mockSessionManager.relaunchOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: true }), + headers: { "Content-Type": "application/json" }, }); - (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]); + const res = await orchestratorsPOST(req); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.orchestrators).toHaveLength(1); - expect(data.orchestrators[0].id).toBe("my-app-orchestrator"); - expect(data.projectName).toBe("My App"); + expect(res.status).toBe(201); + expect(mockSessionManager.relaunchOrchestrator).toHaveBeenCalledWith({ + projectId: "my-app", + systemPrompt: expect.stringContaining("# My App Orchestrator"), + }); + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); }); - it("returns 400 when project parameter is missing", async () => { - const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators")); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toMatch(/Missing project query parameter/); - }); + it("uses spawnOrchestrator when clean is false or omitted", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); - it("returns 404 for unknown project", async () => { - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"), - ); - expect(res.status).toBe(404); - const data = await res.json(); - expect(data.error).toMatch(/Unknown project/); - }); + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: false }), + headers: { "Content-Type": "application/json" }, + }); + await orchestratorsPOST(req); - it("returns 500 when list fails", async () => { - (mockSessionManager.list as ReturnType).mockRejectedValueOnce( - new Error("boom"), - ); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(500); - const data = await res.json(); - expect(data.error).toBe("boom"); + expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalled(); + expect(mockSessionManager.relaunchOrchestrator).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx deleted file mode 100644 index 8aaba6d00..000000000 --- a/packages/web/src/__tests__/orchestrators.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import OrchestratorsRoute from "@/app/orchestrators/page"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; - -// ── Mocks ───────────────────────────────────────────────────────────── - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: vi.fn(), - }), -})); - -vi.mock("next/link", () => ({ - default: ({ children, href }: { children: React.ReactNode; href: string }) => ( - {children} - ), -})); - -vi.mock("@/lib/services", () => ({ - getServices: vi.fn(), -})); - -vi.mock("@/lib/project-name", () => ({ - getAllProjects: vi.fn(), -})); - -global.fetch = vi.fn(); - -// ── Tests ───────────────────────────────────────────────────────────── - -describe("Orchestrators Page (OrchestratorsRoute)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the page with searchParams and listed orchestrators", async () => { - const mockSessionManager = { - list: vi.fn().mockResolvedValue([ - { - id: "app-orchestrator", - projectId: "my-app", - status: "working", - activity: "active", - metadata: { role: "orchestrator" }, - createdAt: new Date(), - lastActivityAt: new Date(), - }, - ]), - }; - - (getServices as any).mockResolvedValue({ - config: { - projects: { - "my-app": { name: "My App", sessionPrefix: "app" }, - }, - }, - sessionManager: mockSessionManager, - }); - - (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("My App")).toBeInTheDocument(); - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("shows error when project is missing in searchParams", async () => { - const searchParams = Promise.resolve({}); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Missing Project")).toBeInTheDocument(); - }); - - it("shows error when project is not found in config", async () => { - (getServices as any).mockResolvedValue({ - config: { projects: {} }, - sessionManager: { list: vi.fn() }, - }); - (getAllProjects as any).mockReturnValue([]); - - const searchParams = Promise.resolve({ project: "ghost" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument(); - }); - - it("handles service errors gracefully", async () => { - (getServices as any).mockRejectedValue(new Error("Database down")); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Database down")).toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/__tests__/project-detail-route.test.ts b/packages/web/src/__tests__/project-detail-route.test.ts index 297917714..3f9fd67c0 100644 --- a/packages/web/src/__tests__/project-detail-route.test.ts +++ b/packages/web/src/__tests__/project-detail-route.test.ts @@ -458,6 +458,37 @@ describe("/api/projects/[id]", () => { expect(readFileSync(path.join(repoDir, "agent-orchestrator.yaml"), "utf-8")).toContain("agent: codex"); }); + it("POST repair preserves wrapped defaults so the project can start with its intended agent", async () => { + const repoDir = path.join(tempRoot, "broken-defaults"); + mkdirSync(repoDir, { recursive: true }); + writeFileSync( + path.join(repoDir, "agent-orchestrator.yaml"), + [ + "defaults:", + " agent: codex", + " runtime: tmux", + " workspace: worktree", + "projects:", + " broken-defaults:", + ` path: ${repoDir}`, + " name: Broken Defaults", + "", + ].join("\n"), + ); + const effectiveId = registerProjectInGlobalConfig("broken-defaults", "Broken Defaults", repoDir); + + const { POST } = await import("@/app/api/projects/[id]/route"); + const response = await POST(makeRequest("POST", undefined, effectiveId), { + params: Promise.resolve({ id: effectiveId }), + }); + + expect(response.status).toBe(200); + const localYaml = readFileSync(path.join(repoDir, "agent-orchestrator.yaml"), "utf-8"); + expect(localYaml).toContain("agent: codex"); + expect(localYaml).toContain("runtime: tmux"); + expect(localYaml).toContain("workspace: worktree"); + }); + it("POST repairs wrapped local .yml configs in place", async () => { const repoDir = path.join(tempRoot, "broken-yml"); mkdirSync(repoDir, { recursive: true }); diff --git a/packages/web/src/__tests__/review-api.test.ts b/packages/web/src/__tests__/review-api.test.ts new file mode 100644 index 000000000..1852ee1e2 --- /dev/null +++ b/packages/web/src/__tests__/review-api.test.ts @@ -0,0 +1,328 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createActivitySignal, + createInitialCanonicalLifecycle, + createCodeReviewStore, + type OrchestratorConfig, + type Session, + type SessionManager, +} from "@aoagents/ao-core"; + +const { mockConfig, mockSessionManager } = vi.hoisted(() => ({ + mockConfig: { + 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: {}, + } satisfies OrchestratorConfig, + mockSessionManager: { + get: vi.fn(), + list: vi.fn(), + spawn: vi.fn(), + spawnOrchestrator: vi.fn(), + ensureOrchestrator: vi.fn(), + relaunchOrchestrator: vi.fn(), + restore: vi.fn(), + kill: vi.fn(), + cleanup: vi.fn(), + send: vi.fn(), + claimPR: vi.fn(), + } satisfies SessionManager, +})); + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + config: mockConfig, + sessionManager: mockSessionManager, + })), +})); + +function makeRequest(url: string, init?: RequestInit): NextRequest { + return new NextRequest( + new URL(url, "http://localhost:3000"), + init as ConstructorParameters[1], + ); +} + +function makeSession(overrides: Partial = {}): 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, + }; +} + +import { POST } from "@/app/api/reviews/route"; +import { POST as POST_EXECUTE } from "@/app/api/reviews/execute/route"; +import { GET as GET_FINDINGS } from "@/app/api/reviews/findings/route"; +import { POST as POST_SEND } from "@/app/api/reviews/send/route"; + +let tmpHome: string; +let originalHome: string | undefined; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "ao-web-review-api-")); + originalHome = process.env["HOME"]; + process.env["HOME"] = tmpHome; + createCodeReviewStore("app").deleteAll(); + mockSessionManager.get.mockReset(); + mockSessionManager.get.mockResolvedValue(makeSession()); +}); + +afterEach(() => { + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + rmSync(tmpHome, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +describe("POST /api/reviews", () => { + it("requests a review run for a worker session", async () => { + const response = await POST( + makeRequest("/api/reviews", { + method: "POST", + body: JSON.stringify({ sessionId: "app-1" }), + }), + ); + + expect(response.status).toBe(201); + const payload = (await response.json()) as { + run: { linkedSessionId: string; reviewerSessionId: string; status: string }; + }; + expect(payload.run).toMatchObject({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "queued", + }); + expect(createCodeReviewStore("app").listRuns()).toHaveLength(1); + }); + + it("returns 400 when a session belongs to an unknown project", async () => { + mockSessionManager.get.mockResolvedValueOnce(makeSession({ projectId: "missing-project" })); + + const response = await POST( + makeRequest("/api/reviews", { + method: "POST", + body: JSON.stringify({ sessionId: "app-1" }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "Unknown project for session app-1: missing-project", + }); + }); + + it("returns 400 when a review is requested for an orchestrator session", async () => { + mockSessionManager.get.mockResolvedValueOnce( + makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), + ); + + const response = await POST( + makeRequest("/api/reviews", { + method: "POST", + body: JSON.stringify({ sessionId: "app-orchestrator" }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "Cannot request code review for orchestrator session: app-orchestrator", + }); + }); +}); + +describe("GET /api/reviews/findings", () => { + it("returns stored findings for a review run", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + }); + store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "Missing empty state", + body: "The todo list should render a clear empty state.", + filePath: "src/App.tsx", + startLine: 12, + }); + + const response = await GET_FINDINGS( + makeRequest(`/api/reviews/findings?projectId=app&runId=${run.id}`), + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + run: { id: string }; + findings: Array<{ title: string; filePath: string }>; + }; + expect(payload.run.id).toBe(run.id); + expect(payload.findings).toEqual([ + expect.objectContaining({ + title: "Missing empty state", + filePath: "src/App.tsx", + }), + ]); + }); +}); + +describe("POST /api/reviews/send", () => { + it("sends open findings to the linked worker session", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + prNumber: 7, + }); + const finding = store.createFinding({ + runId: run.id, + linkedSessionId: "app-1", + severity: "warning", + title: "Missing empty state", + body: "The todo list should render a clear empty state.", + filePath: "src/App.tsx", + startLine: 12, + }); + + const response = await POST_SEND( + makeRequest("/api/reviews/send", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: run.id }), + }), + ); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + run: { status: string; sentFindingCount: number; openFindingCount: number }; + sentFindingCount: number; + message: string; + }; + expect(payload.sentFindingCount).toBe(1); + expect(payload.run).toMatchObject({ + status: "waiting_update", + sentFindingCount: 1, + openFindingCount: 0, + }); + expect(payload.message).toContain("Missing empty state"); + expect(mockSessionManager.send).toHaveBeenCalledWith( + "app-1", + expect.stringContaining("Missing empty state"), + ); + expect(store.getFinding(finding.id)).toMatchObject({ status: "sent_to_agent" }); + }); + + it("returns 409 when there are no open findings to send", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "clean", + }); + + const response = await POST_SEND( + makeRequest("/api/reviews/send", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: run.id }), + }), + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "No open review findings to send for app-rev-1.", + }); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + }); +}); + +describe("POST /api/reviews/execute", () => { + it("returns 404 when the review run does not exist", async () => { + const response = await POST_EXECUTE( + makeRequest("/api/reviews/execute", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: "review-run-missing" }), + }), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: "Code review run not found: review-run-missing", + }); + }); + + it("returns 409 when the review run is not executable", async () => { + const store = createCodeReviewStore("app"); + const run = store.createRun({ + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "running", + }); + + const response = await POST_EXECUTE( + makeRequest("/api/reviews/execute", { + method: "POST", + body: JSON.stringify({ projectId: "app", runId: run.id }), + }), + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: "Code review run app-rev-1 is running, not queued", + }); + }); +}); diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index b3258b10f..1ccc65a90 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -2,6 +2,22 @@ import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, expect } from "vitest"; +// Node.js 25 exposes a native localStorage stub via --localstorage-file that +// lacks .clear()/.key()/.length. Replace it with a complete in-memory mock so +// tests that call window.localStorage.clear() work on any Node version. +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = String(v); }, + removeItem: (k: string) => { Reflect.deleteProperty(store, k); }, + clear: () => { store = {}; }, + get length() { return Object.keys(store).length; }, + key: (i: number) => Object.keys(store)[i] ?? null, + }; +})(); +Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true }); + expect.extend(matchers); afterEach(() => { cleanup(); diff --git a/packages/web/src/__tests__/webhook-route.test.ts b/packages/web/src/__tests__/webhook-route.test.ts new file mode 100644 index 000000000..0bea074e5 --- /dev/null +++ b/packages/web/src/__tests__/webhook-route.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createInitialCanonicalLifecycle, + createActivitySignal, + type Session, + type SessionManager, + type OrchestratorConfig, + type PluginRegistry, + type SCM, + type LifecycleManager, +} from "@aoagents/ao-core"; + +// Activity event recording is mocked so we can assert what fires without +// touching the real SQLite layer. +const recordActivityEvent = vi.fn(); +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + recordActivityEvent: (event: unknown) => recordActivityEvent(event), + }; +}); + +// ── Mock services + plugin registry ─────────────────────────────────── + +function makeSession(id: string, overrides: Partial = {}): Session { + const lifecycle = createInitialCanonicalLifecycle("worker", new Date()); + lifecycle.session.state = "working"; + lifecycle.session.reason = "task_in_progress"; + lifecycle.session.startedAt = lifecycle.session.lastTransitionAt; + lifecycle.runtime.state = "alive"; + lifecycle.runtime.reason = "process_running"; + return { + id, + projectId: "my-app", + status: "working", + activity: "active", + activitySignal: createActivitySignal("valid", { + activity: "active", + timestamp: new Date(), + source: "native", + }), + lifecycle, + branch: "feat/x", + issueId: null, + pr: { + number: 42, + url: "u", + title: "t", + owner: "acme", + repo: "my-app", + branch: "feat/x", + baseBranch: "main", + isDraft: false, + }, + workspacePath: null, + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +const verifyWebhook = vi.fn(); +const parseWebhook = vi.fn(); +const mockSCM: SCM = { + name: "github", + detectPR: vi.fn(), + getPRState: vi.fn(), + mergePR: vi.fn(), + closePR: vi.fn(), + getCIChecks: vi.fn(), + getCISummary: vi.fn(), + getReviews: vi.fn(), + getReviewDecision: vi.fn(), + getPendingComments: vi.fn(), + getAutomatedComments: vi.fn(), + getMergeability: vi.fn(), + verifyWebhook, + parseWebhook, +} as unknown as SCM; + +const mockRegistry: PluginRegistry = { + register: vi.fn(), + get: vi.fn(() => mockSCM) as PluginRegistry["get"], + list: vi.fn(() => []), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), +}; + +const mockConfig: OrchestratorConfig = { + configPath: "/tmp/agent-orchestrator.yaml", + port: 3000, + readyThresholdMs: 300_000, + defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] }, + projects: { + "my-app": { + name: "My App", + repo: "acme/my-app", + path: "/tmp/my-app", + defaultBranch: "main", + sessionPrefix: "my-app", + scm: { + plugin: "github", + webhook: { enabled: true, path: "/api/webhooks/github", maxBodyBytes: 1024 }, + }, + }, + }, + notifiers: {}, + notificationRouting: { urgent: [], action: [], warning: [], info: [] }, + reactions: {}, +}; + +const mockSessionManager = { + list: vi.fn(async () => [makeSession("s1")]), +} as unknown as SessionManager; + +const mockLifecycle = { + check: vi.fn(async () => {}), +} as unknown as LifecycleManager; + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(async () => ({ + config: mockConfig, + registry: mockRegistry, + sessionManager: mockSessionManager, + lifecycleManager: mockLifecycle, + })), +})); + +import { POST as webhookPOST } from "@/app/api/webhooks/[...slug]/route"; + +function makeWebhookRequest(opts?: { + body?: string; + contentLength?: number; + headers?: Record; +}): Request { + const body = opts?.body ?? JSON.stringify({ action: "synchronize", number: 42 }); + const headers: Record = { + "content-type": "application/json", + ...opts?.headers, + }; + if (opts?.contentLength !== undefined) { + headers["content-length"] = String(opts.contentLength); + } + return new Request("http://localhost:3000/api/webhooks/github", { + method: "POST", + headers, + body, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + recordActivityEvent.mockClear(); + verifyWebhook.mockResolvedValue({ ok: true }); + parseWebhook.mockResolvedValue({ + provider: "github", + kind: "pull_request", + action: "synchronize", + rawEventType: "pull_request", + repository: { owner: "acme", name: "my-app" }, + prNumber: 42, + branch: "feat/x", + data: {}, + }); +}); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("POST /api/webhooks/[...slug] — activity events", () => { + it("rejects unverified webhook with 401 and emits api.webhook_unverified", async () => { + verifyWebhook.mockResolvedValueOnce({ ok: false, reason: "bad signature" }); + const req = makeWebhookRequest({ + headers: { "x-hub-signature-256": "sha256=bogus", "x-forwarded-for": "203.0.113.7" }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(401); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_unverified", + level: "warn", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { + summary: string; + data: Record; + }; + // Critical: signature value must NOT be in data (or anywhere) + expect(JSON.stringify(call)).not.toContain("bogus"); + expect(call.data["slug"]).toBe("github"); + expect(call.data["remoteAddr"]).toBe("203.0.113.7"); + expect(call.data["verificationSupported"]).toBe(true); + expect(call.data["reason"]).toBe("bad signature"); + }); + + it("keeps unsupported webhook verification as 404 while recording audit context", async () => { + vi.mocked(mockRegistry.get).mockReturnValueOnce({ + ...mockSCM, + verifyWebhook: undefined, + } as unknown as SCM); + + const res = await webhookPOST(makeWebhookRequest()); + expect(res.status).toBe(404); + + expect(verifyWebhook).not.toHaveBeenCalled(); + expect(parseWebhook).not.toHaveBeenCalled(); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_unverified", + level: "warn", + summary: expect.stringContaining("verification unsupported"), + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { + data: Record; + }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["verificationSupported"]).toBe(false); + expect(call.data["unsupportedVerificationCount"]).toBe(1); + expect(call.data["reason"]).toBe("verification_unsupported"); + }); + + it("emits api.webhook_rejected when content-length exceeds maxBodyBytes (413)", async () => { + const req = makeWebhookRequest({ contentLength: 2048 }); // > 1024 max + + const res = await webhookPOST(req); + expect(res.status).toBe(413); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_rejected", + level: "warn", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { data: Record }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["contentLength"]).toBe(2048); + expect(call.data["maxBodyBytes"]).toBe(1024); + // No body content captured + expect(JSON.stringify(call)).not.toContain("synchronize"); + }); + + it("emits api.webhook_received with counts (not body) on 202 success", async () => { + const req = makeWebhookRequest({ + headers: { "x-forwarded-for": "192.0.2.1" }, + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(202); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_received", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { data: Record }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["remoteAddr"]).toBe("192.0.2.1"); + expect(call.data["matchedSessions"]).toBe(1); + expect(call.data["parseErrorCount"]).toBe(0); + expect(call.data["lifecycleErrorCount"]).toBe(0); + expect(call.data["projectIds"]).toEqual(["my-app"]); + // Critical: payload body is NOT included + expect(JSON.stringify(call)).not.toContain("synchronize"); + }); + + it("emits api.webhook_received with elevated level when parse/lifecycle errors occurred", async () => { + parseWebhook.mockRejectedValueOnce(new Error("boom")); + // Verification passes so we get into the parse path + verifyWebhook.mockResolvedValueOnce({ ok: true }); + + const res = await webhookPOST(makeWebhookRequest()); + expect(res.status).toBe(202); + + const received = recordActivityEvent.mock.calls.find( + ([e]) => (e as { kind: string }).kind === "api.webhook_received", + ); + expect(received).toBeDefined(); + expect((received![0] as { level: string }).level).toBe("warn"); + expect((received![0] as { data: Record }).data["parseErrorCount"]).toBe(1); + }); + + it("emits api.webhook_failed on 500 outer crash", async () => { + // Force the list() call inside POST to throw, hitting the outer catch. + (mockSessionManager.list as ReturnType).mockRejectedValueOnce( + new Error("session manager exploded"), + ); + + const res = await webhookPOST(makeWebhookRequest()); + expect(res.status).toBe(500); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "api", + kind: "api.webhook_failed", + level: "error", + }), + ); + const call = recordActivityEvent.mock.calls[0]![0] as { data: Record }; + expect(call.data["slug"]).toBe("github"); + expect(call.data["errorMessage"]).toContain("session manager exploded"); + }); + + it("does not emit any event for 404 (unknown path)", async () => { + // Empty config so no candidates match + vi.mocked(mockRegistry.get).mockReturnValueOnce(undefined as unknown as SCM); + const req = new Request("http://localhost:3000/api/webhooks/unknown", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + const res = await webhookPOST(req); + expect(res.status).toBe(404); + // No webhook events for 404 — it's a config issue, not an external signal + const kinds = recordActivityEvent.mock.calls.map(([e]) => (e as { kind: string }).kind); + expect(kinds.filter((k) => k.startsWith("api.webhook_"))).toEqual([]); + }); +}); diff --git a/packages/web/src/app/api/issues/route.ts b/packages/web/src/app/api/issues/route.ts index 6063a67f1..07b18847a 100644 --- a/packages/web/src/app/api/issues/route.ts +++ b/packages/web/src/app/api/issues/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getServices } from "@/lib/services"; import { validateString, validateConfiguredProject } from "@/lib/validation"; -import type { Tracker } from "@aoagents/ao-core"; +import { recordActivityEvent, type Tracker } from "@aoagents/ao-core"; export const dynamic = "force-dynamic"; @@ -95,11 +95,25 @@ export async function POST(request: NextRequest) { project, ); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_created", + summary: `issue created: ${issue.id}`, + data: { issueId: issue.id, addToBacklog: Boolean(body.addToBacklog) }, + }); + return NextResponse.json({ issue: { projectId, ...issue } }, { status: 201 }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to create issue" }, - { status: 500 }, - ); + const reason = err instanceof Error ? err.message : "Failed to create issue"; + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_create_failed", + level: "error", + summary: `issue create failed: ${reason}`, + data: { reason }, + }); + return NextResponse.json({ error: reason }, { status: 500 }); } } diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index 886761693..40e6c3972 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,10 +1,12 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt, generateSessionPrefix } from "@aoagents/ao-core"; +import { generateOrchestratorPrompt, recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; -function classifySpawnError(projectId: string, error: unknown): { +function classifySpawnError( + projectId: string, + error: unknown, +): { status: number; payload: Record; } { @@ -35,46 +37,6 @@ function classifySpawnError(projectId: string, error: unknown): { }; } -/** - * GET /api/orchestrators?project= - * List existing orchestrator sessions for a project. - */ -export async function GET(request: NextRequest) { - const projectId = request.nextUrl.searchParams.get("project"); - - if (!projectId) { - return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 }); - } - - const projectErr = validateIdentifier(projectId, "projectId"); - if (projectErr) { - return NextResponse.json({ error: projectErr }, { status: 400 }); - } - - try { - const { config, sessionManager } = await getServices(); - const configProjectErr = validateConfiguredProject(config.projects, projectId); - if (configProjectErr) { - return NextResponse.json({ error: configProjectErr }, { status: 404 }); - } - const project = config.projects[projectId]; - const sessionPrefix = project.sessionPrefix ?? projectId; - - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes); - - return NextResponse.json({ orchestrators, projectName: project.name }); - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to list orchestrators" }, - { status: 500 }, - ); - } -} - export async function POST(request: NextRequest) { const body = (await request.json().catch(() => null)) as Record | null; if (!body) { @@ -86,6 +48,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: projectErr }, { status: 400 }); } + const clean = body.clean === true; + try { const { config, sessionManager } = await getServices(); const projectId = body.projectId as string; @@ -96,7 +60,17 @@ export async function POST(request: NextRequest) { const project = config.projects[projectId]; const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + const session = clean + ? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt }) + : await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + + recordActivityEvent({ + projectId, + sessionId: session.id, + source: "api", + kind: "api.orchestrator_spawn_requested", + summary: `orchestrator spawn requested for ${projectId}`, + }); return NextResponse.json( { diff --git a/packages/web/src/app/api/projects/[id]/route.ts b/packages/web/src/app/api/projects/[id]/route.ts index f940aefae..3ac591fb9 100644 --- a/packages/web/src/app/api/projects/[id]/route.ts +++ b/packages/web/src/app/api/projects/[id]/route.ts @@ -9,6 +9,7 @@ import { loadConfig, loadGlobalConfig, loadLocalProjectConfigDetailed, + recordActivityEvent, repairWrappedLocalProjectConfig, unregisterProject, writeLocalProjectConfig, @@ -23,6 +24,7 @@ import { stopStaleWindowsPtyHosts } from "@/lib/windows-pty-cleanup"; export const dynamic = "force-dynamic"; const IDENTITY_FIELDS = new Set(["projectId", "path", "repo", "defaultBranch"]); +const EDITABLE_CONFIG_FIELDS = new Set(["agent", "runtime", "tracker", "scm", "reactions"]); function sanitizeString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -31,7 +33,7 @@ function sanitizeString(value: unknown): string | undefined { } function revalidateProjectPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { @@ -112,7 +114,10 @@ function getProjectState(projectId: string) { }; } -function degradedPayload(projectId: string, degradedProject: NonNullable["degradedProject"]>) { +function degradedPayload( + projectId: string, + degradedProject: NonNullable["degradedProject"]>, +) { return { error: degradedProject.resolveError, projectId, @@ -126,10 +131,7 @@ function degradedPayload(projectId: string, degradedProject: NonNullable }, -) { +export async function GET(_request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; const state = getProjectState(id); @@ -172,10 +174,7 @@ export async function GET( } } -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function PATCH(request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; const body = (await request.json().catch(() => null)) as Record | null; @@ -200,7 +199,10 @@ export async function PATCH( } const projectPath = state.globalEntry?.path; if (!projectPath) { - return NextResponse.json({ error: `Project "${id}" is missing a registry path.` }, { status: 409 }); + return NextResponse.json( + { error: `Project "${id}" is missing a registry path.` }, + { status: 409 }, + ); } const localConfigResult = loadLocalProjectConfigDetailed(projectPath); @@ -211,7 +213,8 @@ export async function PATCH( return NextResponse.json({ error: localConfigResult.error }, { status: 400 }); } - const currentConfig: LocalProjectConfig = localConfigResult.kind === "loaded" ? { ...localConfigResult.config } : {}; + const currentConfig: LocalProjectConfig = + localConfigResult.kind === "loaded" ? { ...localConfigResult.config } : {}; const nextConfig: LocalProjectConfig = { ...currentConfig, }; @@ -231,8 +234,7 @@ export async function PATCH( ...(body["tracker"] as Record), } as LocalProjectConfig["tracker"]) : undefined; - nextConfig.tracker = - nextTracker; + nextConfig.tracker = nextTracker; } if (hasOwn("scm")) { const nextScm = @@ -242,8 +244,7 @@ export async function PATCH( ...(body["scm"] as Record), } as LocalProjectConfig["scm"]) : undefined; - nextConfig.scm = - nextScm; + nextConfig.scm = nextScm; } if (hasOwn("reactions")) { nextConfig.reactions = @@ -261,6 +262,16 @@ export async function PATCH( invalidatePortfolioServicesCache(); revalidateProjectPaths(id); + // Record only changed *keys*, never values — config can contain tokens. + const changedKeys = Object.keys(body).filter((k) => EDITABLE_CONFIG_FIELDS.has(k)); + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_updated", + summary: `project updated: ${id}`, + data: { changedKeys }, + }); + return NextResponse.json({ ok: true }); } catch (error) { return NextResponse.json( @@ -270,19 +281,14 @@ export async function PATCH( } } -export async function PUT( - request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { return PATCH(request, context); } -export async function DELETE( - _request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function DELETE(_request: NextRequest, context: { params: Promise<{ id: string }> }) { + let id: string | undefined; try { - const { id } = await context.params; + ({ id } = await context.params); const state = getProjectState(id); if (!state.globalEntry && !state.project && !state.degradedProject) { return NextResponse.json({ error: `Unknown project: ${id}` }, { status: 404 }); @@ -299,7 +305,8 @@ export async function DELETE( return NextResponse.json({ error: `Invalid project ID: ${id}` }, { status: 400 }); } - const workspacePluginName = state.project?.workspace ?? state.config.defaults.workspace ?? "worktree"; + const workspacePluginName = + state.project?.workspace ?? state.config.defaults.workspace ?? "worktree"; const { registry, sessionManager } = await getServices(); await stopProjectSessions(id, sessionManager); await stopStaleWindowsPtyHosts(projectDir); @@ -310,23 +317,34 @@ export async function DELETE( invalidatePortfolioServicesCache(); revalidateProjectPaths(id); + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_removed", + summary: `project removed: ${id}`, + data: { removedStorageDir: hadStorageDir }, + }); + return NextResponse.json({ ok: true, projectId: id, removedStorageDir: hadStorageDir, }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to delete project" }, - { status: 500 }, - ); + const reason = error instanceof Error ? error.message : "Failed to delete project"; + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_remove_failed", + level: "error", + summary: `project remove failed: ${reason}`, + data: { reason }, + }); + return NextResponse.json({ error: reason }, { status: 500 }); } } -export async function POST( - _request: NextRequest, - context: { params: Promise<{ id: string }> }, -) { +export async function POST(_request: NextRequest, context: { params: Promise<{ id: string }> }) { try { const { id } = await context.params; const state = getProjectState(id); @@ -337,7 +355,9 @@ export async function POST( return NextResponse.json({ error: "Project does not need repair." }, { status: 400 }); } - const isWrappedConfigError = state.degradedProject.resolveError.includes("wrapped projects: format"); + const isWrappedConfigError = state.degradedProject.resolveError.includes( + "wrapped projects: format", + ); if (!isWrappedConfigError) { return NextResponse.json( { error: "Automatic repair is not available for this degraded config." }, @@ -349,6 +369,13 @@ export async function POST( invalidatePortfolioServicesCache(); revalidateProjectPaths(id); + recordActivityEvent({ + projectId: id, + source: "api", + kind: "api.project_repaired", + summary: `project repaired: ${id}`, + }); + return NextResponse.json({ ok: true, repaired: true, projectId: id }); } catch (error) { return NextResponse.json( diff --git a/packages/web/src/app/api/projects/reload/route.ts b/packages/web/src/app/api/projects/reload/route.ts index e1a13f333..ecacc9dde 100644 --- a/packages/web/src/app/api/projects/reload/route.ts +++ b/packages/web/src/app/api/projects/reload/route.ts @@ -1,5 +1,10 @@ import { NextResponse } from "next/server"; -import { ConfigNotFoundError, getGlobalConfigPath, loadConfig } from "@aoagents/ao-core"; +import { + ConfigNotFoundError, + getGlobalConfigPath, + loadConfig, + recordActivityEvent, +} from "@aoagents/ao-core"; import { invalidatePortfolioServicesCache } from "@/lib/services"; export const dynamic = "force-dynamic"; @@ -25,10 +30,19 @@ export async function POST() { invalidatePortfolioServicesCache(); const config = loadReloadConfig(); + const projectCount = Object.keys(config.projects).length; + const degradedCount = Object.keys(config.degradedProjects).length; + recordActivityEvent({ + source: "api", + kind: "api.config_reloaded", + summary: `config reloaded: ${projectCount} projects, ${degradedCount} degraded`, + data: { projectCount, degradedCount }, + }); + return NextResponse.json({ reloaded: true, - projectCount: Object.keys(config.projects).length, - degradedCount: Object.keys(config.degradedProjects).length, + projectCount, + degradedCount, }); } catch (error) { return NextResponse.json( diff --git a/packages/web/src/app/api/projects/route.ts b/packages/web/src/app/api/projects/route.ts index 0947558a6..ca9c1c9ba 100644 --- a/packages/web/src/app/api/projects/route.ts +++ b/packages/web/src/app/api/projects/route.ts @@ -8,6 +8,7 @@ import { getGlobalConfigPath, loadConfig, migrateToGlobalConfig, + recordActivityEvent, registerProjectInGlobalConfig, } from "@aoagents/ao-core"; import { revalidatePath } from "next/cache"; @@ -33,7 +34,7 @@ function isGitRepository(projectPath: string): boolean { } function revalidatePortfolioPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { @@ -108,6 +109,12 @@ export async function POST(request: NextRequest) { ); invalidatePortfolioServicesCache(); revalidatePortfolioPaths(registeredProjectId); + recordActivityEvent({ + projectId: registeredProjectId, + source: "api", + kind: "api.project_added", + summary: `project added: ${registeredProjectId}`, + }); return NextResponse.json({ ok: true, projectId: registeredProjectId }, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to add project"; @@ -124,6 +131,14 @@ export async function POST(request: NextRequest) { if (pathAlreadyRegistered) { const existingProjectId = pathAlreadyRegistered[1]; const suggestedProjectId = generateExternalId(resolvedPath); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.project_add_rejected", + level: "warn", + summary: `project add rejected: path already registered`, + data: { reason: "path_already_registered", existingProjectId, statusCode: 409 }, + }); return NextResponse.json( { error: message, @@ -138,6 +153,14 @@ export async function POST(request: NextRequest) { if (idAlreadyRegistered) { const existingProjectId = idAlreadyRegistered[1]; const suggestedProjectId = generateExternalId(resolvedPath); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.project_add_rejected", + level: "warn", + summary: `project add rejected: id already registered`, + data: { reason: "id_already_registered", existingProjectId, statusCode: 409 }, + }); return NextResponse.json( { error: message, diff --git a/packages/web/src/app/api/prs/[id]/merge/route.ts b/packages/web/src/app/api/prs/[id]/merge/route.ts index 9ab5aed1a..c996a60f9 100644 --- a/packages/web/src/app/api/prs/[id]/merge/route.ts +++ b/packages/web/src/app/api/prs/[id]/merge/route.ts @@ -1,4 +1,5 @@ import { type NextRequest } from "next/server"; +import { recordActivityEvent, type OrchestratorConfig } from "@aoagents/ao-core"; import { getServices, getSCM } from "@/lib/services"; import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; @@ -11,15 +12,21 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return jsonWithCorrelation({ error: "Invalid PR number" }, { status: 400 }, correlationId); } const prNumber = Number(id); + let configForObservation: OrchestratorConfig | undefined; + let projectId: string | undefined; + let sessionId: string | undefined; try { const { config, registry, sessionManager } = await getServices(); + configForObservation = config; const sessions = await sessionManager.list(); const session = sessions.find((s) => s.pr?.number === prNumber); if (!session?.pr) { return jsonWithCorrelation({ error: "PR not found" }, { status: 404 }, correlationId); } + projectId = session.projectId; + sessionId = session.id; const project = config.projects[session.projectId]; const scm = getSCM(registry, project); @@ -34,6 +41,15 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< // Validate PR is in a mergeable state const state = await scm.getPRState(session.pr); if (state !== "open") { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.pr_merge_rejected", + level: "warn", + summary: `PR ${prNumber} merge rejected: state is ${state}`, + data: { prNumber, prState: state, statusCode: 409 }, + }); return jsonWithCorrelation( { error: `PR is ${state}, not open` }, { status: 409 }, @@ -43,6 +59,15 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< const mergeability = await scm.getMergeability(session.pr); if (!mergeability.mergeable) { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.pr_merge_rejected", + level: "warn", + summary: `PR ${prNumber} merge rejected: not mergeable`, + data: { prNumber, blockers: mergeability.blockers, statusCode: 422 }, + }); return jsonWithCorrelation( { error: "PR is not mergeable", blockers: mergeability.blockers }, { status: 422 }, @@ -63,13 +88,22 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< sessionId: session.id, data: { prNumber }, }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.pr_merge_requested", + summary: `PR ${prNumber} merge requested`, + data: { prNumber, method: "squash" }, + }); return jsonWithCorrelation( { ok: true, prNumber, method: "squash" }, { status: 200 }, correlationId, ); } catch (err) { - const { config } = await getServices().catch(() => ({ config: undefined })); + const config = + configForObservation ?? (await getServices().catch(() => ({ config: undefined }))).config; if (config) { recordApiObservation({ config, @@ -79,10 +113,22 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< startedAt, outcome: "failure", statusCode: 500, + projectId, + sessionId, reason: err instanceof Error ? err.message : "Failed to merge PR", data: { prNumber }, }); } + const reason = err instanceof Error ? err.message : "Failed to merge PR"; + recordActivityEvent({ + projectId, + sessionId, + source: "api", + kind: "api.pr_merge_failed", + level: "error", + summary: `PR ${prNumber} merge failed: ${reason}`, + data: { prNumber, reason }, + }); return jsonWithCorrelation( { error: err instanceof Error ? err.message : "Failed to merge PR" }, { status: 500 }, diff --git a/packages/web/src/app/api/reviews/execute/route.ts b/packages/web/src/app/api/reviews/execute/route.ts new file mode 100644 index 000000000..df9c7cfd0 --- /dev/null +++ b/packages/web/src/app/api/reviews/execute/route.ts @@ -0,0 +1,63 @@ +import { + CodeReviewRunNotExecutableError, + CodeReviewRunNotFoundError, + createShellCodeReviewRunner, + executeCodeReviewRun, + SessionNotFoundError, +} from "@aoagents/ao-core"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { validateConfiguredProject, validateIdentifier } from "@/lib/validation"; + +export async function POST(request: Request) { + const correlationId = getCorrelationId(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return jsonWithCorrelation({ error: "Invalid JSON body" }, { status: 400 }, correlationId); + } + + const projectIdErr = validateIdentifier(body.projectId, "projectId"); + if (projectIdErr) { + return jsonWithCorrelation({ error: projectIdErr }, { status: 400 }, correlationId); + } + + const runIdErr = validateIdentifier(body.runId, "runId"); + if (runIdErr) { + return jsonWithCorrelation({ error: runIdErr }, { status: 400 }, correlationId); + } + + try { + const { config, sessionManager } = await getServices(); + const projectId = String(body.projectId); + const configuredProjectErr = validateConfiguredProject(config.projects, projectId); + if (configuredProjectErr) { + return jsonWithCorrelation({ error: configuredProjectErr }, { status: 404 }, correlationId); + } + + const command = process.env["AO_CODE_REVIEW_COMMAND"]; + const run = await executeCodeReviewRun( + { + config, + sessionManager, + force: body.force === true, + ...(command ? { runReviewer: createShellCodeReviewRunner(command) } : {}), + }, + { projectId, runId: String(body.runId) }, + ); + + return jsonWithCorrelation({ run }, { status: 200 }, correlationId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewRunNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewRunNotExecutableError) { + return jsonWithCorrelation({ error: error.message }, { status: 409 }, correlationId); + } + + const message = error instanceof Error ? error.message : "Failed to execute review"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/reviews/findings/route.ts b/packages/web/src/app/api/reviews/findings/route.ts new file mode 100644 index 000000000..0d0e1050f --- /dev/null +++ b/packages/web/src/app/api/reviews/findings/route.ts @@ -0,0 +1,53 @@ +import { createCodeReviewStore } from "@aoagents/ao-core"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { validateConfiguredProject, validateIdentifier } from "@/lib/validation"; + +export async function GET(request: Request) { + const correlationId = getCorrelationId(request); + const { searchParams } = new URL(request.url); + const projectId = searchParams.get("projectId"); + const runId = searchParams.get("runId"); + + const projectIdErr = validateIdentifier(projectId, "projectId"); + if (projectIdErr) { + return jsonWithCorrelation({ error: projectIdErr }, { status: 400 }, correlationId); + } + + const runIdErr = validateIdentifier(runId, "runId"); + if (runIdErr) { + return jsonWithCorrelation({ error: runIdErr }, { status: 400 }, correlationId); + } + + try { + const { config } = await getServices(); + const safeProjectId = String(projectId); + const safeRunId = String(runId); + const configuredProjectErr = validateConfiguredProject(config.projects, safeProjectId); + if (configuredProjectErr) { + return jsonWithCorrelation({ error: configuredProjectErr }, { status: 404 }, correlationId); + } + + const store = createCodeReviewStore(safeProjectId); + const run = store.getRun(safeRunId); + if (!run) { + return jsonWithCorrelation( + { error: `Review run not found: ${safeRunId}` }, + { status: 404 }, + correlationId, + ); + } + + return jsonWithCorrelation( + { + run, + findings: store.listFindings({ runId: safeRunId }), + }, + { status: 200 }, + correlationId, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load review findings"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/reviews/route.ts b/packages/web/src/app/api/reviews/route.ts new file mode 100644 index 000000000..e5592be41 --- /dev/null +++ b/packages/web/src/app/api/reviews/route.ts @@ -0,0 +1,87 @@ +import { + CodeReviewInvalidSessionError, + SessionNotFoundError, + triggerCodeReviewForSession, +} from "@aoagents/ao-core"; +import { getReviewPageData, resolveReviewProjectFilter } from "@/lib/review-page-data"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation"; + +const MAX_REVIEW_SUMMARY_LENGTH = 2_000; + +export async function GET(request: Request) { + const correlationId = getCorrelationId(request); + const { searchParams } = new URL(request.url); + const projectFilter = resolveReviewProjectFilter(searchParams.get("project") ?? undefined); + const pageData = await getReviewPageData(projectFilter); + + if (pageData.dashboardLoadError) { + return jsonWithCorrelation( + { + error: pageData.dashboardLoadError, + runs: pageData.runs, + }, + { status: 500 }, + correlationId, + ); + } + + return jsonWithCorrelation( + { + runs: pageData.runs, + workerOptions: pageData.workerOptions, + orchestrators: pageData.orchestrators, + projectName: pageData.projectName, + selectedProjectId: pageData.selectedProjectId ?? null, + }, + { status: 200 }, + correlationId, + ); +} + +export async function POST(request: Request) { + const correlationId = getCorrelationId(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return jsonWithCorrelation({ error: "Invalid JSON body" }, { status: 400 }, correlationId); + } + + const sessionIdErr = validateIdentifier(body.sessionId, "sessionId"); + if (sessionIdErr) { + return jsonWithCorrelation({ error: sessionIdErr }, { status: 400 }, correlationId); + } + + let summary: string | undefined; + if (body.summary !== undefined) { + const summaryErr = validateString(body.summary, "summary", MAX_REVIEW_SUMMARY_LENGTH); + if (summaryErr) { + return jsonWithCorrelation({ error: summaryErr }, { status: 400 }, correlationId); + } + summary = stripControlChars(String(body.summary)); + } + + try { + const { config, sessionManager } = await getServices(); + const run = await triggerCodeReviewForSession( + { config, sessionManager }, + { + sessionId: String(body.sessionId), + requestedBy: "web", + summary, + }, + ); + + return jsonWithCorrelation({ run }, { status: 201 }, correlationId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewInvalidSessionError) { + return jsonWithCorrelation({ error: error.message }, { status: 400 }, correlationId); + } + + const message = error instanceof Error ? error.message : "Failed to request review"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/reviews/send/route.ts b/packages/web/src/app/api/reviews/send/route.ts new file mode 100644 index 000000000..5d87f1cce --- /dev/null +++ b/packages/web/src/app/api/reviews/send/route.ts @@ -0,0 +1,56 @@ +import { + CodeReviewNoOpenFindingsError, + CodeReviewRunNotFoundError, + sendCodeReviewFindingsToAgent, + SessionNotFoundError, +} from "@aoagents/ao-core"; +import { getCorrelationId, jsonWithCorrelation } from "@/lib/observability"; +import { getServices } from "@/lib/services"; +import { validateConfiguredProject, validateIdentifier } from "@/lib/validation"; + +export async function POST(request: Request) { + const correlationId = getCorrelationId(request); + const body = (await request.json().catch(() => null)) as Record | null; + if (!body) { + return jsonWithCorrelation({ error: "Invalid JSON body" }, { status: 400 }, correlationId); + } + + const projectIdErr = validateIdentifier(body.projectId, "projectId"); + if (projectIdErr) { + return jsonWithCorrelation({ error: projectIdErr }, { status: 400 }, correlationId); + } + + const runIdErr = validateIdentifier(body.runId, "runId"); + if (runIdErr) { + return jsonWithCorrelation({ error: runIdErr }, { status: 400 }, correlationId); + } + + try { + const { config, sessionManager } = await getServices(); + const projectId = String(body.projectId); + const configuredProjectErr = validateConfiguredProject(config.projects, projectId); + if (configuredProjectErr) { + return jsonWithCorrelation({ error: configuredProjectErr }, { status: 404 }, correlationId); + } + + const result = await sendCodeReviewFindingsToAgent( + { config, sessionManager }, + { projectId, runId: String(body.runId) }, + ); + + return jsonWithCorrelation(result, { status: 200 }, correlationId); + } catch (error) { + if (error instanceof SessionNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewRunNotFoundError) { + return jsonWithCorrelation({ error: error.message }, { status: 404 }, correlationId); + } + if (error instanceof CodeReviewNoOpenFindingsError) { + return jsonWithCorrelation({ error: error.message }, { status: 409 }, correlationId); + } + + const message = error instanceof Error ? error.message : "Failed to send review findings"; + return jsonWithCorrelation({ error: message }, { status: 500 }, correlationId); + } +} diff --git a/packages/web/src/app/api/sessions/[id]/kill/route.ts b/packages/web/src/app/api/sessions/[id]/kill/route.ts index cf4a6b117..a71bac6e9 100644 --- a/packages/web/src/app/api/sessions/[id]/kill/route.ts +++ b/packages/web/src/app/api/sessions/[id]/kill/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { validateIdentifier } from "@/lib/validation"; import { getServices } from "@/lib/services"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -34,6 +34,13 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< projectId, sessionId: id, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_kill_requested", + summary: `session kill requested: ${id}`, + }); return jsonWithCorrelation({ ok: true, sessionId: id }, { status: 200 }, correlationId); } catch (err) { if (err instanceof SessionNotFoundError) { @@ -56,6 +63,15 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< }); } const msg = err instanceof Error ? err.message : "Failed to kill session"; + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_kill_failed", + level: "error", + summary: `session kill failed: ${msg}`, + data: { reason: msg }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/[id]/message/route.ts b/packages/web/src/app/api/sessions/[id]/message/route.ts index 463927af5..1df3e4bae 100644 --- a/packages/web/src/app/api/sessions/[id]/message/route.ts +++ b/packages/web/src/app/api/sessions/[id]/message/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { getServices } from "@/lib/services"; import { stripControlChars, validateIdentifier, validateString } from "@/lib/validation"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -79,6 +79,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ sessionId: id, data: { messageLength: message.length }, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_sent", + summary: `message sent to session ${id}`, + data: { messageLength: message.length }, + }); return jsonWithCorrelation({ success: true }, { status: 200 }, correlationId); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); @@ -98,6 +106,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (err instanceof SessionNotFoundError) { return jsonWithCorrelation({ error: err.message }, { status: 404 }, correlationId); } + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_failed", + level: "error", + summary: `session message failed: ${errorMsg}`, + data: { messageLength: message.length, reason: errorMsg }, + }); console.error("Failed to send message:", errorMsg); return jsonWithCorrelation( { error: `Failed to send message: ${errorMsg}` }, diff --git a/packages/web/src/app/api/sessions/[id]/remap/route.ts b/packages/web/src/app/api/sessions/[id]/remap/route.ts index d71ea32f9..5c5f0bb83 100644 --- a/packages/web/src/app/api/sessions/[id]/remap/route.ts +++ b/packages/web/src/app/api/sessions/[id]/remap/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { validateIdentifier } from "@/lib/validation"; import { getServices } from "@/lib/services"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -33,6 +33,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ projectId, sessionId: id, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_remap_requested", + summary: `session remap requested: ${id}`, + }); return jsonWithCorrelation( { ok: true, sessionId: id, opencodeSessionId }, { status: 200 }, @@ -74,6 +81,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ reason: msg, }); } + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_remap_failed", + level: "warn", + summary: `session remap failed: ${msg}`, + data: { reason: msg, statusCode: 422 }, + }); return jsonWithCorrelation({ error: msg }, { status: 422 }, correlationId); } if (config) { @@ -90,6 +106,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ reason: msg, }); } + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_remap_failed", + level: "error", + summary: `session remap failed: ${msg}`, + data: { reason: msg, statusCode: 500 }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/[id]/restore/route.ts b/packages/web/src/app/api/sessions/[id]/restore/route.ts index 5372b9012..7c1bea560 100644 --- a/packages/web/src/app/api/sessions/[id]/restore/route.ts +++ b/packages/web/src/app/api/sessions/[id]/restore/route.ts @@ -6,6 +6,8 @@ import { SessionNotRestorableError, WorkspaceMissingError, SessionNotFoundError, + recordActivityEvent, + type OrchestratorConfig, } from "@aoagents/ao-core"; import { getCorrelationId, @@ -24,9 +26,13 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return jsonWithCorrelation({ error: idErr }, { status: 400 }, correlationId); } + let configForAttribution: OrchestratorConfig | undefined; + let projectIdForAttribution: string | undefined; + try { const { config, sessionManager } = await getServices(); - const projectId = resolveProjectIdForSessionId(config, id); + configForAttribution = config; + projectIdForAttribution = resolveProjectIdForSessionId(config, id); const restored = await sessionManager.restore(id); recordApiObservation({ @@ -37,9 +43,16 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< startedAt, outcome: "success", statusCode: 200, - projectId: restored.projectId ?? projectId, + projectId: restored.projectId ?? projectIdForAttribution, sessionId: id, }); + recordActivityEvent({ + projectId: restored.projectId ?? projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_requested", + summary: `session restore requested: ${id}`, + }); return jsonWithCorrelation( { @@ -54,29 +67,61 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< if (err instanceof SessionNotFoundError) { return jsonWithCorrelation({ error: err.message }, { status: 404 }, correlationId); } + if (!configForAttribution) { + const serviceContext = await getServices().catch(() => undefined); + configForAttribution = serviceContext?.config; + projectIdForAttribution = configForAttribution + ? resolveProjectIdForSessionId(configForAttribution, id) + : undefined; + } if (err instanceof SessionNotRestorableError) { + recordActivityEvent({ + projectId: projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_failed", + level: "warn", + summary: `session restore failed: ${err.message}`, + data: { reason: err.message, statusCode: 409 }, + }); return jsonWithCorrelation({ error: err.message }, { status: 409 }, correlationId); } if (err instanceof WorkspaceMissingError) { + recordActivityEvent({ + projectId: projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_failed", + level: "warn", + summary: `session restore failed: ${err.message}`, + data: { reason: err.message, statusCode: 422 }, + }); return jsonWithCorrelation({ error: err.message }, { status: 422 }, correlationId); } - const { config } = await getServices().catch(() => ({ config: undefined })); - const projectId = config ? resolveProjectIdForSessionId(config, id) : undefined; - if (config) { + if (configForAttribution) { recordApiObservation({ - config, + config: configForAttribution, method: "POST", path: "/api/sessions/[id]/restore", correlationId, startedAt, outcome: "failure", statusCode: 500, - projectId, + projectId: projectIdForAttribution, sessionId: id, reason: err instanceof Error ? err.message : "Failed to restore session", }); } const msg = err instanceof Error ? err.message : "Failed to restore session"; + recordActivityEvent({ + projectId: projectIdForAttribution, + sessionId: id, + source: "api", + kind: "api.session_restore_failed", + level: "error", + summary: `session restore failed: ${msg}`, + data: { reason: msg, statusCode: 500 }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/[id]/send/route.ts b/packages/web/src/app/api/sessions/[id]/send/route.ts index 44a8198df..8c0b67cd2 100644 --- a/packages/web/src/app/api/sessions/[id]/send/route.ts +++ b/packages/web/src/app/api/sessions/[id]/send/route.ts @@ -1,7 +1,7 @@ import { type NextRequest } from "next/server"; import { validateIdentifier, validateString, stripControlChars } from "@/lib/validation"; import { getServices } from "@/lib/services"; -import { SessionNotFoundError } from "@aoagents/ao-core"; +import { SessionNotFoundError, recordActivityEvent } from "@aoagents/ao-core"; import { getCorrelationId, jsonWithCorrelation, @@ -55,6 +55,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ sessionId: id, data: { messageLength: message.length }, }); + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_sent", + summary: `message sent to session ${id}`, + data: { messageLength: message.length }, + }); return jsonWithCorrelation( { ok: true, sessionId: id, message }, { status: 200 }, @@ -82,6 +90,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }); } const msg = err instanceof Error ? err.message : "Failed to send message"; + recordActivityEvent({ + projectId, + sessionId: id, + source: "api", + kind: "api.session_message_failed", + level: "error", + summary: `session message failed: ${msg}`, + data: { messageLength: message.length, reason: msg }, + }); return jsonWithCorrelation({ error: msg }, { status: 500 }, correlationId); } } diff --git a/packages/web/src/app/api/sessions/patches/route.ts b/packages/web/src/app/api/sessions/patches/route.ts index 1ba9fa9de..0aa9df61b 100644 --- a/packages/web/src/app/api/sessions/patches/route.ts +++ b/packages/web/src/app/api/sessions/patches/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { ? projectFilter : undefined; - const coreSessions = await sessionManager.list(requestedProjectId); + const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects); // Convert to dashboard format diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 539740f16..c9645bafd 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -10,7 +10,7 @@ import { import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; import { filterProjectSessions } from "@/lib/project-utils"; import { settlesWithin } from "@/lib/async-utils"; -import { isDashboardSessionTerminal, type DashboardOrchestratorLink } from "@/lib/types"; +import { type DashboardOrchestratorLink } from "@/lib/types"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; @@ -136,8 +136,8 @@ export async function GET(request: Request) { let dashboardSessions = workerSessions.map(sessionToDashboard); if (activeOnly) { - const activeIndices = dashboardSessions - .map((session, index) => (!isDashboardSessionTerminal(session) ? index : -1)) + const activeIndices = workerSessions + .map((session, index) => (!isTerminalSession(session) ? index : -1)) .filter((index) => index !== -1); workerSessions = activeIndices.map((index) => workerSessions[index]); dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); diff --git a/packages/web/src/app/api/setup-labels/route.ts b/packages/web/src/app/api/setup-labels/route.ts index 7b0c32465..75a3b597e 100644 --- a/packages/web/src/app/api/setup-labels/route.ts +++ b/packages/web/src/app/api/setup-labels/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -42,6 +43,15 @@ export async function POST() { } } + const created = results.filter((r) => r.status === "created").length; + const exists = results.filter((r) => r.status === "exists").length; + recordActivityEvent({ + source: "api", + kind: "api.labels_setup", + summary: `labels setup complete: ${created} created, ${exists} exists`, + data: { created, exists, total: results.length }, + }); + return NextResponse.json({ results }); } catch (err) { return NextResponse.json( diff --git a/packages/web/src/app/api/spawn/route.ts b/packages/web/src/app/api/spawn/route.ts index 192a4a557..8fcc37fe5 100644 --- a/packages/web/src/app/api/spawn/route.ts +++ b/packages/web/src/app/api/spawn/route.ts @@ -1,4 +1,5 @@ import { type NextRequest } from "next/server"; +import { recordActivityEvent } from "@aoagents/ao-core"; import { validateIdentifier, validateString, validateConfiguredProject } from "@/lib/validation"; import { getServices } from "@/lib/services"; import { sessionToDashboard } from "@/lib/serialize"; @@ -50,6 +51,14 @@ export async function POST(request: NextRequest) { reason: projectErr, data: { issueId: body.issueId }, }); + recordActivityEvent({ + projectId, + source: "api", + kind: "api.session_spawn_rejected", + level: "warn", + summary: `session spawn rejected: ${projectErr}`, + data: { reason: "project_not_configured" }, + }); return jsonWithCorrelation({ error: projectErr }, { status: 404 }, correlationId); } @@ -75,6 +84,17 @@ export async function POST(request: NextRequest) { sessionId: session.id, data: { issueId: session.issueId }, }); + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "api", + kind: "api.session_spawn_requested", + summary: `session spawn requested for ${session.projectId}`, + data: { + issueId: session.issueId ?? undefined, + hasPrompt: Boolean(prompt), + }, + }); return jsonWithCorrelation( { session: sessionToDashboard(session) }, diff --git a/packages/web/src/app/api/verify/route.ts b/packages/web/src/app/api/verify/route.ts index ef2ab0661..b713aaf52 100644 --- a/packages/web/src/app/api/verify/route.ts +++ b/packages/web/src/app/api/verify/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { getVerifyIssues, getServices } from "@/lib/services"; import { validateConfiguredProject } from "@/lib/validation"; -import type { Tracker } from "@aoagents/ao-core"; +import { recordActivityEvent, type Tracker } from "@aoagents/ao-core"; export const dynamic = "force-dynamic"; @@ -25,6 +25,9 @@ export async function GET() { * Body: { issueId: string, projectId: string, action: "verify" | "fail", comment?: string } */ export async function POST(req: NextRequest) { + let issueId: string | undefined; + let projectId: string | undefined; + let action: "verify" | "fail" | undefined; try { const body = (await req.json().catch(() => null)) as | { @@ -37,12 +40,13 @@ export async function POST(req: NextRequest) { if (!body) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const { issueId, projectId, action, comment } = body as { + let comment: string | undefined; + ({ issueId, projectId, action, comment } = body as { issueId: string; projectId: string; action: "verify" | "fail"; comment?: string; - }; + }); if (!issueId || !projectId || !action) { return NextResponse.json( @@ -96,11 +100,25 @@ export async function POST(req: NextRequest) { ); } + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_verified", + summary: `issue ${issueId} ${action}`, + data: { issueId, action }, + }); + return NextResponse.json({ ok: true }); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to update issue" }, - { status: 500 }, - ); + const reason = err instanceof Error ? err.message : "Failed to update issue"; + recordActivityEvent({ + projectId, + source: "api", + kind: "api.issue_verify_failed", + level: "error", + summary: `issue verify failed: ${reason}`, + data: { issueId, action, reason }, + }); + return NextResponse.json({ error: reason }, { status: 500 }); } } diff --git a/packages/web/src/app/api/webhooks/[...slug]/route.ts b/packages/web/src/app/api/webhooks/[...slug]/route.ts index 9ccf878ad..08660dac8 100644 --- a/packages/web/src/app/api/webhooks/[...slug]/route.ts +++ b/packages/web/src/app/api/webhooks/[...slug]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { buildWebhookRequest, @@ -9,11 +10,35 @@ import { export const dynamic = "force-dynamic"; +const WEBHOOK_PATH_PREFIX = "/api/webhooks/"; + +function deriveSlug(pathname: string): string { + return pathname.startsWith(WEBHOOK_PATH_PREFIX) + ? pathname.slice(WEBHOOK_PATH_PREFIX.length) + : pathname; +} + +function deriveRemoteAddr(request: Request): string | undefined { + // Next.js does not expose the socket peer address on Request. The standard + // proxy headers are the only signal — first hop in x-forwarded-for is the + // original client. Sanitizer in recordActivityEvent does not redact IPs; + // they are intentionally retained for security audit (per issue #1656). + const xff = request.headers.get("x-forwarded-for"); + if (xff) { + const first = xff.split(",")[0]?.trim(); + if (first) return first; + } + return request.headers.get("x-real-ip") ?? undefined; +} + export async function POST(request: Request): Promise { + const pathname = new URL(request.url).pathname; + const slug = deriveSlug(pathname); + const remoteAddr = deriveRemoteAddr(request); + try { const services = await getServices(); - const path = new URL(request.url).pathname; - const candidates = findWebhookProjects(services.config, services.registry, path); + const candidates = findWebhookProjects(services.config, services.registry, pathname); if (candidates.length === 0) { return NextResponse.json( @@ -36,6 +61,19 @@ export async function POST(request: Request): Promise { Number.isFinite(contentLength) && contentLength > maxBodyBytes ) { + recordActivityEvent({ + source: "api", + kind: "api.webhook_rejected", + level: "warn", + summary: `webhook payload exceeded ${maxBodyBytes} bytes for ${slug}`, + data: { + slug, + remoteAddr, + contentLength, + maxBodyBytes, + reason: "payload_too_large", + }, + }); return NextResponse.json( { error: "Webhook payload exceeds configured maxBodyBytes" }, { status: 413 }, @@ -50,12 +88,21 @@ export async function POST(request: Request): Promise { const sessionIds = new Set(); const projectIds = new Set(); let verified = false; + let verificationSupported = false; + let unsupportedVerificationCount = 0; const errors: string[] = []; const parseErrors: string[] = []; const lifecycleErrors: string[] = []; for (const candidate of candidates) { - const verification = await candidate.scm.verifyWebhook?.(webhookRequest, candidate.project); + if (!candidate.scm.verifyWebhook) { + unsupportedVerificationCount += 1; + errors.push("Webhook verification not supported by SCM plugin"); + continue; + } + + verificationSupported = true; + const verification = await candidate.scm.verifyWebhook(webhookRequest, candidate.project); if (!verification?.ok) { if (verification?.reason) errors.push(verification.reason); continue; @@ -93,12 +140,52 @@ export async function POST(request: Request): Promise { } if (!verified) { + const unsupportedOnly = !verificationSupported && unsupportedVerificationCount > 0; + recordActivityEvent({ + source: "api", + kind: "api.webhook_unverified", + level: "warn", + summary: unsupportedOnly + ? `webhook verification unsupported for ${slug}` + : `webhook signature verification failed for ${slug}`, + data: { + slug, + remoteAddr, + candidateCount: candidates.length, + verificationSupported, + unsupportedVerificationCount, + reason: unsupportedOnly + ? "verification_unsupported" + : (errors[0] ?? "verification_failed"), + }, + }); return NextResponse.json( - { error: errors[0] ?? "Webhook verification failed", ok: false }, - { status: 401 }, + { + error: unsupportedOnly + ? "No SCM webhook configured for this path" + : (errors[0] ?? "Webhook verification failed"), + ok: false, + verificationSupported, + }, + { status: unsupportedOnly ? 404 : 401 }, ); } + recordActivityEvent({ + source: "api", + kind: "api.webhook_received", + level: parseErrors.length > 0 || lifecycleErrors.length > 0 ? "warn" : "info", + summary: `webhook accepted for ${slug}: ${sessionIds.size} session(s) matched`, + data: { + slug, + remoteAddr, + projectIds: [...projectIds], + matchedSessions: sessionIds.size, + parseErrorCount: parseErrors.length, + lifecycleErrorCount: lifecycleErrors.length, + }, + }); + return NextResponse.json( { ok: true, @@ -111,6 +198,17 @@ export async function POST(request: Request): Promise { { status: 202 }, ); } catch (err) { + recordActivityEvent({ + source: "api", + kind: "api.webhook_failed", + level: "error", + summary: `webhook pipeline crashed for ${slug}`, + data: { + slug, + remoteAddr, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); return NextResponse.json( { error: err instanceof Error ? err.message : "Failed to process SCM webhook" }, { status: 500 }, diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 5de3264aa..e7e6d9b16 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -719,10 +719,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { justify-content: center; min-height: 36px; border-radius: 999px; + font-family: inherit; font-size: 12px; font-weight: 650; padding: 0 15px; text-decoration: none; + cursor: pointer; transition: transform 120ms ease, border-color 120ms ease, @@ -747,6 +749,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { text-decoration: none; } +.session-ended-summary__primary:focus-visible, +.session-ended-summary__secondary:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + .session-ended-summary__evidence { display: grid; gap: 6px; @@ -1211,6 +1219,41 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { gap: 6px; } +.workspace-mode-switch { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 8px; + border: 1px solid var(--color-border-subtle); + border-radius: 5px; + background: var(--color-bg-primary); + padding: 2px; +} + +.workspace-mode-switch__item { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 3px; + color: var(--color-text-muted); + text-decoration: none; + font-size: 11px; + font-weight: 500; +} + +.workspace-mode-switch__item:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + text-decoration: none; +} + +.workspace-mode-switch__item--active { + background: var(--color-bg-surface); + color: var(--color-text-primary); + box-shadow: inset 0 0 0 1px var(--color-border-subtle); +} + .dashboard-app-btn { display: inline-flex; align-items: center; @@ -1230,6 +1273,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { color 100ms ease, transform 120ms cubic-bezier(0.23, 1, 0.32, 1); } +.dashboard-app-btn--icon { + width: 27px; + padding: 0; + justify-content: center; +} .dashboard-app-btn:hover { background: var(--color-bg-subtle); @@ -1328,6 +1376,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .topbar-status-pill__label { color: inherit; } +.topbar-status-pill__dot--working { + background: var(--color-status-working); +} +.topbar-status-pill__dot--attention { + background: var(--color-status-attention); +} /* Branch pill — compact topbar variant */ .topbar-branch-pill { @@ -1409,6 +1463,412 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 0; } +.dashboard-notification-wrap { + position: relative; + display: inline-flex; +} + +.dashboard-notification-btn { + min-width: 31px; + justify-content: center; + padding: 0 8px; +} + +.dashboard-notification-btn--open { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.dashboard-notification-btn__count { + min-width: 15px; + height: 15px; + padding: 0 4px; + border-radius: 999px; + background: var(--color-status-attention); + color: var(--color-bg-base); + font-size: 10px; + line-height: 15px; + text-align: center; +} + +.dashboard-notification-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 55; + width: 430px; + max-width: calc(100vw - 24px); + max-height: min(520px, calc(100vh - 72px)); + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--color-border-subtle); + background: var(--color-bg-elevated); + box-shadow: + 0 18px 42px rgba(0, 0, 0, 0.42), + 0 1px 0 rgba(255, 255, 255, 0.04) inset; +} + +.dashboard-notification-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 4px; + background: color-mix(in srgb, var(--color-bg-muted) 38%, transparent); +} + +.dashboard-notification-panel__title { + color: var(--color-text-primary); + font-size: 15px; + font-weight: 600; + line-height: 1.2; +} + +.dashboard-notification-panel__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.dashboard-notification-panel__mark-all { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + white-space: nowrap; + border: 1px solid var(--color-border-subtle); + border-radius: 5px; + background: transparent; + color: var(--color-text-secondary); + padding: 0 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.dashboard-notification-panel__mark-all:hover:not(:disabled) { + border-color: var(--color-border-strong); + background: color-mix(in srgb, var(--color-bg-subtle) 80%, transparent); + color: var(--color-text-primary); +} + +.dashboard-notification-panel__mark-all:disabled { + cursor: default; + opacity: 0.45; +} + +.dashboard-notification-tabs { + display: flex; + gap: 18px; + padding: 0 14px; + border-bottom: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-bg-muted) 38%, transparent); +} + +.dashboard-notification-tab { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + border: 0; + background: transparent; + color: var(--color-text-secondary); + padding: 0; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.dashboard-notification-tab span { + min-width: 20px; + height: 20px; + border-radius: 999px; + background: var(--color-bg-muted); + color: var(--color-text-secondary); + padding: 0 6px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 20px; + text-align: center; +} + +.dashboard-notification-tab:hover { + color: var(--color-text-primary); +} + +.dashboard-notification-tab[aria-selected="true"] { + color: var(--color-text-primary); +} + +.dashboard-notification-tab[aria-selected="true"]::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + border-radius: 999px; + background: var(--color-text-primary); + content: ""; +} + +.dashboard-notification-tab[aria-selected="true"] span { + background: color-mix(in srgb, var(--color-bg-subtle) 90%, var(--color-bg-muted)); + color: var(--color-text-primary); +} + +.dashboard-notification-panel__error { + margin: 10px; + border: 1px solid color-mix(in srgb, var(--color-status-error) 28%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--color-status-error) 10%, transparent); + color: var(--color-status-error); + padding: 8px; + font-size: 11px; + line-height: 1.4; +} + +.dashboard-notification-panel__empty { + padding: 18px 12px; + color: var(--color-text-muted); + font-size: 12px; + text-align: center; +} + +.dashboard-notification-list { + display: flex; + max-height: 448px; + flex-direction: column; + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; +} + +.dashboard-notification-item { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + gap: 10px; + border-left: 3px solid var(--color-border-default); + border-bottom: 1px solid var(--color-border-subtle); + padding: 12px; +} + +.dashboard-notification-item:hover { + background: var(--color-bg-subtle); +} + +.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-accent) 6%, transparent); +} + +.dashboard-notification-item--read { + opacity: 0.72; +} + +.dashboard-notification-item--urgent { + border-left-color: var(--color-status-error); +} + +.dashboard-notification-item--urgent.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-status-error) 8%, transparent); +} + +.dashboard-notification-item--action { + border-left-color: var(--color-status-attention); +} + +.dashboard-notification-item--action.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-status-attention) 9%, transparent); +} + +.dashboard-notification-item--warning { + border-left-color: var(--color-accent-amber); +} + +.dashboard-notification-item--info { + border-left-color: var(--color-accent); +} + +.dashboard-notification-item--success { + border-left-color: var(--color-accent-green); +} + +.dashboard-notification-item--success.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-accent-green) 8%, transparent); +} + +.dashboard-notification-item__status-dot { + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 999px; + background: var(--color-border-strong); +} + +.dashboard-notification-item--unread .dashboard-notification-item__status-dot { + background: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 14%, transparent); +} + +.dashboard-notification-item--urgent.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-status-error); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-status-error) 14%, transparent); +} + +.dashboard-notification-item--action.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-status-attention); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-status-attention) 14%, transparent); +} + +.dashboard-notification-item--warning.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-accent-amber); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-amber) 14%, transparent); +} + +.dashboard-notification-item--success.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-accent-green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-green) 14%, transparent); +} + +.dashboard-notification-item__content { + min-width: 0; +} + +.dashboard-notification-item__topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} + +.dashboard-notification-item__priority { + display: inline-flex; + align-items: center; + height: 18px; + border-radius: 4px; + background: var(--color-bg-muted); + color: var(--color-text-secondary); + padding: 0 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.dashboard-notification-item--success .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-accent-green) 12%, transparent); + color: var(--color-accent-green); +} + +.dashboard-notification-item--urgent .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-status-error) 12%, transparent); + color: var(--color-status-error); +} + +.dashboard-notification-item--action .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-status-attention) 14%, transparent); + color: var(--color-status-attention); +} + +.dashboard-notification-item--warning .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-accent-amber) 14%, transparent); + color: var(--color-accent-amber); +} + +.dashboard-notification-item__time { + color: var(--color-text-muted); + font-size: 10px; + font-family: var(--font-mono); +} + +.dashboard-notification-item__message { + margin: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 500; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.dashboard-notification-item__meta { + display: flex; + min-width: 0; + gap: 8px; + margin-top: 5px; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.dashboard-notification-item__meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-notification-item__links { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.dashboard-notification-item__links a { + display: inline-flex; + align-items: center; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 4px; + background: var(--color-bg-muted); + color: var(--color-accent); + padding: 2px 6px; + font-size: 11px; + text-decoration: none; +} + +.dashboard-notification-item__links a:hover { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + text-decoration: none; +} + +.dashboard-notification-item__side { + display: flex; + align-items: flex-start; +} + +.dashboard-notification-item__read-btn { + height: 22px; + white-space: nowrap; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--color-accent); + padding: 0 6px; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.dashboard-notification-item__read-btn:hover { + border-color: color-mix(in srgb, var(--color-accent) 30%, transparent); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + +.dashboard-notification-item__read-label { + color: var(--color-text-muted); + font-size: 11px; +} + /* Session title hidden on mobile (pills move to second row instead) */ @media (max-width: 640px) { .dashboard-app-header__session-title { @@ -1423,6 +1883,19 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { left: 0; width: calc(100vw - 16px); } + + .dashboard-notification-panel { + right: -6px; + width: min(430px, calc(100vw - 16px)); + } + + .dashboard-notification-item { + grid-template-columns: 10px minmax(0, 1fr); + } + + .dashboard-notification-item__side { + grid-column: 2; + } } .dashboard-shell--desktop { @@ -1491,7 +1964,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 0 16px 16px; } -.dashboard-main__body .kanban-board-wrap, .dashboard-main__body .board-wrapper { flex: 1; min-height: 0; @@ -2935,6 +3407,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 5px 10px 7px; } +.session-card__footer-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + .card__status { flex: 1; min-width: 0; @@ -3012,6 +3491,27 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { color: var(--color-accent-amber); } +.session-card__review-control { + font-size: 10.5px; + font-family: var(--font-sans); + font-weight: 600; + padding: 2px 8px 2px 6px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); + background: color-mix(in srgb, var(--color-accent) 5%, transparent); + color: var(--color-accent); +} + +.session-card__review-control:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + border-color: color-mix(in srgb, var(--color-accent) 36%, transparent); +} + +.session-card__review-control:disabled { + cursor: wait; + opacity: 0.72; +} + .btn--danger { width: 26px; height: 26px; @@ -3846,7 +4346,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px 8px; + height: 48px; + padding: 0 12px; border-bottom: 1px solid var(--sidebar-border); flex-shrink: 0; } @@ -4483,6 +4984,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .project-sidebar__sess-row--active { background: transparent; + border-left: 2px solid var(--color-accent-amber); + padding-left: 24px; } /* Session label */ @@ -4627,6 +5130,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { font-size: 11px; } +.project-sidebar__empty-hint { + display: block; + font-size: 10px; + margin-top: 3px; + color: var(--color-text-muted); +} + .project-sidebar__footer { margin-top: auto; } @@ -5093,6 +5603,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { position: relative; margin-top: 14px; overflow-x: auto; + flex-shrink: 0; } .board-section-head { @@ -5224,8 +5735,497 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 0; } +/* ── Review workbench ─────────────────────────────────────────────── */ + +.review-dashboard-main { + overflow-y: auto; + padding: 18px 18px 20px; +} + +.review-main-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; + border-bottom: 1px solid var(--color-border-subtle); + padding-bottom: 14px; +} + +.review-kanban-board { + grid-template-columns: repeat(var(--kanban-column-count), minmax(250px, 1fr)); + min-width: 1750px; +} + +.review-kanban-column { + min-height: 420px; +} + +.review-column-hint { + margin-top: 5px; + min-height: 28px; + font-size: 10.5px; + line-height: 1.35; + color: var(--color-text-tertiary); +} + +.review-new-menu { + position: relative; +} + +.review-new-menu__popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 30; + width: min(320px, calc(100vw - 48px)); + max-height: 360px; + overflow-y: auto; + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-bg-surface); + box-shadow: 0 16px 36px rgb(0 0 0 / 0.26); + padding: 5px; +} + +.review-new-menu__item { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + gap: 3px; + border-radius: 4px; + padding: 8px; + text-align: left; + color: var(--color-text-secondary); +} + +.review-new-menu__item:hover:not(:disabled) { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.review-new-menu__item:disabled { + cursor: wait; + opacity: 0.65; +} + +.review-new-menu__item-title, +.review-new-menu__item-meta { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-new-menu__item-title { + font-size: 12px; + font-weight: 600; +} + +.review-new-menu__item-meta { + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-text-muted); +} + +.review-column-dot[data-review-column="queued"] { + background: var(--color-text-tertiary); +} + +.review-column-dot[data-review-column="reviewing"] { + background: var(--color-status-review); +} + +.review-column-dot[data-review-column="triage"] { + background: var(--color-status-attention); +} + +.review-column-dot[data-review-column="waiting"] { + background: var(--color-accent); +} + +.review-column-dot[data-review-column="clean"] { + background: var(--color-status-ready); +} + +.review-column-dot[data-review-column="failed"] { + background: var(--color-status-error); +} + +.review-column-dot[data-review-column="outdated"] { + background: var(--color-text-muted); +} + +.review-card { + min-height: 166px; + border-left-color: var(--color-border-default); +} + +.review-card[data-review-status="running"] { + border-left-color: var(--color-status-review); +} + +.review-card[data-review-status="needs_triage"] { + border-left-color: var(--color-status-attention); +} + +.review-card[data-review-status="sent_to_agent"], +.review-card[data-review-status="waiting_update"] { + border-left-color: var(--color-accent); +} + +.review-card[data-review-status="clean"] { + border-left-color: var(--color-status-ready); +} + +.review-card[data-review-status="failed"], +.review-card[data-review-status="cancelled"] { + border-left-color: var(--color-status-error); +} + +.review-card[data-review-status="outdated"] { + border-left-color: var(--color-text-muted); + opacity: 0.78; +} + +.review-card .card__id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-card .card__alerts { + margin-top: auto; +} + +.review-card .session-card__footer { + margin-top: 0; +} + +.review-card .session-card__footer-actions { + gap: 5px; +} + +.review-card .session-card__footer-actions .session-card__control { + height: 24px; + padding-right: 7px; + padding-left: 7px; +} + +.review-card__disabled-control { + cursor: default; + opacity: 0.58; +} + +.review-card__disabled-control:hover { + border-color: var(--color-border-default); + background: var(--color-bg-elevated); + color: var(--color-text-secondary); +} + +.review-card__finding-alert .alert-row__text button { + display: block; + width: 100%; + min-width: 0; + overflow: hidden; + color: inherit; + font: inherit; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-card__finding-alert .alert-row__text button:hover { + color: var(--color-text-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.review-detail-backdrop { + position: fixed; + inset: 0; + z-index: 80; + background: rgba(0, 0, 0, 0.42); +} + +.review-detail-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 81; + display: flex; + width: min(440px, calc(100vw - 18px)); + flex-direction: column; + border-left: 1px solid var(--color-border-default); + background: var(--color-bg-elevated); + box-shadow: -18px 0 42px rgba(0, 0, 0, 0.28); +} + +.review-detail-panel__header { + display: flex; + align-items: flex-start; + gap: 16px; + justify-content: space-between; + border-bottom: 1px solid var(--color-border-subtle); + padding: 18px 18px 14px; +} + +.review-detail-panel__eyebrow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +.review-detail-panel__title { + margin-top: 4px; + font-size: 16px; + font-weight: 600; + line-height: 1.35; + color: var(--color-text-primary); +} + +.review-detail-panel__close { + display: inline-flex; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-default); + border-radius: 4px; + color: var(--color-text-secondary); + font-size: 18px; + line-height: 1; +} + +.review-detail-panel__close:hover { + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.review-detail-panel__meta, +.review-detail-panel__actions, +.review-detail-panel__summary { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 12px 18px 0; +} + +.review-detail-panel__meta span, +.review-detail-panel__actions a { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + background: var(--color-bg-subtle); + padding: 3px 7px; + font-size: 10.5px; + color: var(--color-text-secondary); +} + +.review-detail-panel__actions a { + border-color: color-mix(in srgb, var(--color-accent) 22%, transparent); + background: color-mix(in srgb, var(--color-accent) 5%, transparent); + color: var(--color-accent); + font-weight: 600; + text-decoration: none; +} + +.review-detail-panel__actions a:hover { + border-color: color-mix(in srgb, var(--color-accent) 36%, transparent); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + +.review-detail-panel__notice { + margin: 12px 18px 0; + border: 1px solid color-mix(in srgb, var(--color-status-attention) 30%, transparent); + border-radius: 5px; + background: color-mix(in srgb, var(--color-status-attention) 8%, transparent); + padding: 9px 10px; + color: var(--color-text-secondary); + font-size: 11.5px; + line-height: 1.45; +} + +.review-detail-panel__summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-top: 16px; +} + +.review-detail-panel__summary-item { + border: 1px solid var(--color-border-subtle); + border-radius: 5px; + background: var(--color-bg-subtle); + padding: 8px; +} + +.review-detail-panel__summary-item span { + display: block; + font-size: 9.5px; + letter-spacing: 0.05em; + color: var(--color-text-muted); + text-transform: uppercase; +} + +.review-detail-panel__summary-item strong { + display: block; + margin-top: 4px; + overflow: hidden; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-detail-panel__content { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding: 16px 18px 18px; +} + +.review-detail-panel__empty, +.review-detail-panel__error { + border: 1px dashed var(--color-border-default); + border-radius: 6px; + padding: 18px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.45; +} + +.review-detail-panel__error { + border-color: color-mix(in srgb, var(--color-status-error) 35%, transparent); + color: var(--color-status-error); +} + +.review-detail-finding { + border: 1px solid var(--color-border-subtle); + border-left: 3px solid var(--color-text-muted); + border-radius: 6px; + background: var(--card-bg); + padding: 11px 12px 12px; +} + +.review-detail-finding[data-severity="warning"] { + border-left-color: var(--color-status-attention); +} + +.review-detail-finding[data-severity="error"] { + border-left-color: var(--color-status-error); +} + +.review-detail-finding[data-severity="info"] { + border-left-color: var(--color-accent); +} + +.review-detail-finding__header { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.review-detail-finding__header span { + border: 1px solid var(--color-border-subtle); + border-radius: 3px; + padding: 2px 6px; + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--color-text-muted); + text-transform: lowercase; +} + +.review-detail-finding h3 { + color: var(--color-text-primary); + font-size: 12.5px; + font-weight: 600; + line-height: 1.35; +} + +.review-detail-finding code { + display: inline-block; + max-width: 100%; + overflow: hidden; + margin-top: 7px; + border: 1px solid var(--color-border-subtle); + border-radius: 3px; + background: var(--color-bg-subtle); + padding: 2px 5px; + color: var(--color-text-secondary); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-detail-finding p { + margin-top: 9px; + color: var(--color-text-secondary); + font-size: 11.5px; + line-height: 1.5; +} + +.review-empty-state { + margin: 36px auto 0; + max-width: 460px; + border: 1px dashed var(--color-border-default); + border-radius: 7px; + padding: 28px; + text-align: center; + color: var(--color-text-secondary); +} + +.review-empty-state__title { + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); +} + +.review-empty-state__body { + margin-top: 7px; + font-size: 12px; + line-height: 1.55; + color: var(--color-text-muted); +} + +.review-empty-state__link { + margin-top: 14px; + display: inline-flex; + border: 1px solid var(--color-border-default); + border-radius: 5px; + padding: 7px 10px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + text-decoration: none; +} + +.review-empty-state__link:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + text-decoration: none; +} + +@media (max-width: 767px) { + .review-kanban-board { + min-width: 0; + } +} + /* ── Done / Terminated collapsible bar ────────────────────────────────── */ +.done-bar { + flex-shrink: 0; +} + .done-bar__toggle { display: flex; align-items: center; diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx deleted file mode 100644 index 63444c915..000000000 --- a/packages/web/src/app/orchestrators/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { Metadata } from "next"; -import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; -import { generateSessionPrefix } from "@aoagents/ao-core"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; - -export const dynamic = "force-dynamic"; - -export async function generateMetadata(props: { - searchParams: Promise<{ project?: string }>; -}): Promise { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - let projectName = "Orchestrator"; - if (projectId) { - const projects = getAllProjects(); - const project = projects.find((p) => p.id === projectId); - if (project) { - projectName = project.name; - } - } - return { title: { absolute: `ao | ${projectName} - Orchestrator` } }; -} - -export default async function OrchestratorsRoute(props: { - searchParams: Promise<{ project?: string }>; -}) { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - - if (!projectId) { - return ( -
-
-

- Missing Project -

-

- No project specified. Please provide a project parameter. -

-
-
- ); - } - - let orchestrators: Orchestrator[] = []; - let projectName = projectId; - let error: string | null = null; - - try { - const { config, sessionManager } = await getServices(); - const project = config.projects[projectId]; - - if (!project) { - error = `Project "${projectId}" not found`; - } else { - projectName = project.name; - const sessionPrefix = project.sessionPrefix ?? projectId; - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - orchestrators = mapSessionsToOrchestrators( - allSessions, - sessionPrefix, - project.name, - allSessionPrefixes, - ); - } - } catch (err) { - error = err instanceof Error ? err.message : "Failed to load orchestrators"; - } - - return ( - - ); -} diff --git a/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx new file mode 100644 index 000000000..e94256838 --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let mockPathname = "/projects/proj-1"; +let mockParams: Record = { projectId: "proj-1" }; + +vi.mock("next/navigation", () => ({ + useParams: () => mockParams, + usePathname: () => mockPathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }), +})); + +vi.mock("@/providers/MuxProvider", () => ({ + useMuxOptional: () => ({ status: "connecting", sessions: [], lastError: null }), +})); + +vi.mock("@/hooks/useSessionEvents", () => ({ + useSessionEvents: ({ initialSessions }: { initialSessions: unknown[] }) => ({ + sessions: initialSessions, + liveSessionsResolved: true, + attentionLevels: {}, + loadError: null, + }), +})); + +vi.mock("@/components/ProjectSidebar", () => ({ + ProjectSidebar: (props: { activeProjectId?: string; orchestrators?: unknown[] }) => ( +
+ ), +})); + +import { ProjectLayoutClient } from "../project-layout-client"; + +const projects = [{ id: "proj-1", name: "Project One", sessionPrefix: "proj-1" }]; +const orchestrators = [{ id: "proj-1-orchestrator", projectId: "proj-1" }]; + +beforeEach(() => { + mockPathname = "/projects/proj-1"; + mockParams = { projectId: "proj-1" }; +}); + +describe("ProjectLayoutClient", () => { + it("renders children and sidebar", () => { + render( + +
Page
+
, + ); + + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + }); + + it("passes activeProjectId from route params to sidebar", () => { + mockParams = { projectId: "proj-1" }; + + render( + +
+ , + ); + + expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1"); + }); + + it("passes initialOrchestrators directly to sidebar", () => { + render( + +
+ , + ); + + const sidebar = screen.getByTestId("sidebar"); + expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators); + }); + + it("resets mobile sidebar when pathname changes", async () => { + const { rerender } = render( + +
+ , + ); + + // Simulate pathname change + mockPathname = "/projects/proj-1/sessions/sess-1"; + + await act(async () => { + rerender( + +
+ , + ); + }); + + // Sidebar wrapper should not have the mobile-open class + const wrapper = document.querySelector(".sidebar-wrapper"); + expect(wrapper?.classList.contains("sidebar-wrapper--mobile-open")).toBe(false); + }); +}); diff --git a/packages/web/src/app/projects/[projectId]/layout.tsx b/packages/web/src/app/projects/[projectId]/layout.tsx new file mode 100644 index 000000000..330afda9c --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function ProjectIdLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/packages/web/src/app/projects/[projectId]/loading.test.tsx b/packages/web/src/app/projects/[projectId]/loading.test.tsx index 54d978a5b..61726031e 100644 --- a/packages/web/src/app/projects/[projectId]/loading.test.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.test.tsx @@ -8,7 +8,8 @@ describe("ProjectRouteLoading", () => { expect(screen.getByText("Agent Orchestrator")).toBeInTheDocument(); expect(screen.getByText("Loading project…")).toBeInTheDocument(); - expect(screen.getByText("Projects")).toBeInTheDocument(); - expect(screen.getByLabelText("Loading project dashboard")).toBeInTheDocument(); + // Sidebar is owned by ProjectLayoutClient — no duplicate skeleton sidebar here + expect(screen.queryByText("Projects")).not.toBeInTheDocument(); + expect(screen.getByText("Working")).toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/projects/[projectId]/loading.tsx b/packages/web/src/app/projects/[projectId]/loading.tsx index cc385e2fd..7df4da536 100644 --- a/packages/web/src/app/projects/[projectId]/loading.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.tsx @@ -1,43 +1,13 @@ -function ProjectLoadingSidebar() { - return ( - - ); -} - export default function ProjectRouteLoading() { return ( -
-
+
+
-
- -
- -
-
-
-
-
- +
- -
-
-
-
-
-
-
diff --git a/packages/web/src/app/projects/[projectId]/page.test.tsx b/packages/web/src/app/projects/[projectId]/page.test.tsx index bb1ab40f6..8ba4b4d21 100644 --- a/packages/web/src/app/projects/[projectId]/page.test.tsx +++ b/packages/web/src/app/projects/[projectId]/page.test.tsx @@ -36,6 +36,33 @@ vi.mock("@/components/Dashboard", () => ({ import ProjectPage from "./page"; describe("ProjectPage", () => { + it("renders the dashboard inside a bounded flex item owned by the project shell", async () => { + hoisted.getProjectRouteDataMock.mockResolvedValue({ + projectId: "project-1", + project: { id: "project-1" }, + projects: [{ id: "project-1", name: "Project 1" }], + degradedProject: null, + }); + hoisted.getDashboardPageDataMock.mockResolvedValue({ + sessions: [], + selectedProjectId: "project-1", + projectName: "Project 1", + projects: [{ id: "project-1", name: "Project 1" }], + orchestrators: [], + attentionZones: "simple", + }); + + render(await ProjectPage({ params: Promise.resolve({ projectId: "project-1" }) })); + + expect(screen.getByTestId("dashboard").parentElement).toHaveClass( + "flex", + "min-h-0", + "min-w-0", + "flex-1", + ); + expect(screen.getByTestId("dashboard").parentElement).not.toHaveClass("min-h-screen"); + }); + it("renders degraded project state when the project is degraded", async () => { hoisted.getProjectRouteDataMock.mockResolvedValue({ projectId: "broken", diff --git a/packages/web/src/app/projects/[projectId]/page.tsx b/packages/web/src/app/projects/[projectId]/page.tsx index 2c677fb1f..92582e82a 100644 --- a/packages/web/src/app/projects/[projectId]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/page.tsx @@ -29,7 +29,7 @@ export default async function ProjectPage(props: { const pageData = await getDashboardPageData(projectId); return ( -
+
{ + setMobileSidebarOpen(false); + }, [pathname]); + + const mux = useMuxOptional(); + const { sessions, liveSessionsResolved } = useSessionEvents({ + initialSessions, + muxSessions: mux?.status === "connected" ? mux.sessions : undefined, + muxLastError: mux?.lastError, + attentionZones: "simple", + }); + + const handleToggleSidebar = useCallback(() => { + if (isMobile) { + setMobileSidebarOpen((v) => !v); + } else { + setSidebarCollapsed((v) => !v); + } + }, [isMobile]); + + return ( + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} /> + )} + {children} +
+
+ + ); +} diff --git a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx index 3e7be8541..39c70fcab 100644 --- a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx @@ -1 +1,459 @@ -export { default } from "../../../../sessions/[id]/page"; +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { isOrchestratorSession } from "@aoagents/ao-core/types"; +import { SessionDetail } from "@/components/SessionDetail"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; +import { + type DashboardSession, + type ActivityState, + getAttentionLevel, +} from "@/lib/types"; +import { activityIcon } from "@/lib/activity-icons"; +import type { ProjectInfo } from "@/lib/project-name"; +import { getSessionTitle } from "@/lib/format"; +import { useMuxSessionActivity } from "@/hooks/useMuxSessionActivity"; +import { projectSessionPath } from "@/lib/routes"; +import { fetchJsonWithTimeout } from "@/lib/client-fetch"; + +function truncate(s: string, max: number): string { + const codePoints = Array.from(s); + return codePoints.length > max ? codePoints.slice(0, max).join("") + "..." : s; +} + +function buildSessionTitle( + session: DashboardSession, + prefixByProject: Map, + activityOverride?: ActivityState | null, +): string { + const id = session.id; + const activity = activityOverride !== undefined ? activityOverride : session.activity; + const emoji = activity ? (activityIcon[activity] ?? "") : ""; + const allPrefixes = [...prefixByProject.values()]; + const isOrchestrator = isOrchestratorSession( + session, + prefixByProject.get(session.projectId), + allPrefixes, + ); + const detail = isOrchestrator ? "Orchestrator Terminal" : truncate(getSessionTitle(session), 40); + return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`; +} + +interface ZoneCounts { + merge: number; + respond: number; + review: number; + pending: number; + working: number; + done: number; +} + +interface ProjectSessionsBody { + sessions?: DashboardSession[]; + orchestratorId?: string | null; + orchestrators?: Array<{ id: string; projectId: string; projectName: string }>; +} + +let cachedProjects: ProjectInfo[] | null = null; + +const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000; +const SESSION_FETCH_TIMEOUT_MS = 8000; +const PROJECT_SESSIONS_FETCH_TIMEOUT_MS = 5000; +const PROJECTS_FETCH_TIMEOUT_MS = 5000; +function areProjectsEqual(previous: ProjectInfo[] | null, next: ProjectInfo[]): boolean { + if (!previous || previous.length !== next.length) return false; + return previous.every((p, i) => JSON.stringify(p) === JSON.stringify(next[i])); +} + +function isAbortLikeError(error: unknown): boolean { + if (error instanceof DOMException) return error.name === "AbortError"; + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return msg.includes("aborted") || msg.includes("aborterror"); + } + return false; +} + +function getSessionLoadErrorMessage(error: Error): string { + const normalized = error.message.toLowerCase(); + if (normalized.includes("timed out")) + return "The session request is taking too long. You can retry, or return to the project and reopen a different session."; + if (normalized.includes("network")) + return "The session request failed before the dashboard got a response. Check the local server connection and try again."; + if (normalized.includes("404")) + return "This session is no longer available. It may have been removed while the page was open."; + if (normalized.includes("500")) + return "The server returned an internal error while loading this session. Try again to re-fetch the latest state."; + return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state."; +} + +function LoadingContent() { + return ( +
+
+ + + +
Loading session…
+
+
+ ); +} + +export default function ProjectSessionPage() { + const params = useParams(); + const pathname = usePathname(); + const router = useRouter(); + const id = params.id as string; + const expectedProjectId = typeof params.projectId === "string" ? params.projectId : undefined; + + // Read optimistic session data written by sidebar navigation + const cachedSession = (() => { + if (typeof sessionStorage === "undefined") return null; + try { + const raw = sessionStorage.getItem(`ao-session-nav:${id}`); + if (raw) { + sessionStorage.removeItem(`ao-session-nav:${id}`); + return JSON.parse(raw) as DashboardSession; + } + } catch { + /* ignore */ + } + return null; + })(); + + const [session, setSession] = useState(cachedSession); + const [zoneCounts, setZoneCounts] = useState(null); + const [projectOrchestratorId, setProjectOrchestratorId] = useState( + undefined, + ); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(cachedSession === null); + const [routeError, setRouteError] = useState(null); + const [sessionMissing, setSessionMissing] = useState(false); + const [prefixByProject, setPrefixByProject] = useState>(new Map()); + + const sessionProjectId = session?.projectId ?? null; + const allPrefixes = [...prefixByProject.values()]; + const sessionIsOrchestrator = session + ? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes) + : false; + + const sessionProjectIdRef = useRef(null); + const sessionIsOrchestratorRef = useRef(false); + const resolvedProjectSessionsKeyRef = useRef(null); + const prefixByProjectRef = useRef>(new Map()); + const hasLoadedSessionRef = useRef(cachedSession !== null); + const fetchingSessionRef = useRef(false); + const fetchingProjectSessionsRef = useRef(false); + const sessionFetchControllerRef = useRef(null); + const projectSessionsFetchControllerRef = useRef(null); + const pageUnloadingRef = useRef(false); + const mountedRef = useRef(true); + + const sseActivity = useMuxSessionActivity(id); + + useEffect(() => { + prefixByProjectRef.current = prefixByProject; + }, [prefixByProject]); + + useEffect(() => { + if (session) { + document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity); + } else { + document.title = `${id} | Session Detail`; + } + }, [session, id, prefixByProject, sseActivity]); + + useEffect(() => { + sessionProjectIdRef.current = sessionProjectId; + }, [sessionProjectId]); + + useEffect(() => { + sessionIsOrchestratorRef.current = sessionIsOrchestrator; + }, [sessionIsOrchestrator]); + + useEffect(() => { + if (!session || !projects.some((p) => p.id === session.projectId)) return; + if ( + pathname?.startsWith("/projects/") && + expectedProjectId && + session.projectId !== expectedProjectId + ) { + router.replace(projectSessionPath(session.projectId, session.id)); + } + }, [expectedProjectId, pathname, projects, router, session]); + + useEffect(() => { + if (!sessionIsOrchestrator) setZoneCounts(null); + }, [sessionIsOrchestrator]); + + const fetchProjects = useCallback(async () => { + if (cachedProjects) { + setProjects(cachedProjects); + setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id]))); + } + try { + const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>( + "/api/projects", + { + timeoutMs: PROJECTS_FETCH_TIMEOUT_MS, + timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`, + }, + ); + if (!data?.projects) return; + if (!areProjectsEqual(cachedProjects, data.projects)) { + cachedProjects = data.projects; + setProjects(data.projects); + setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id]))); + } + } catch (err) { + console.error("Failed to fetch projects:", err); + } + }, []); + + const fetchSession = useCallback(async () => { + if (fetchingSessionRef.current) return; + fetchingSessionRef.current = true; + const controller = new AbortController(); + sessionFetchControllerRef.current = controller; + try { + const data = await fetchJsonWithTimeout( + `/api/sessions/${encodeURIComponent(id)}`, + { + signal: controller.signal, + timeoutMs: SESSION_FETCH_TIMEOUT_MS, + timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`, + }, + ); + setSession(data as DashboardSession); + setRouteError(null); + setSessionMissing(false); + hasLoadedSessionRef.current = true; + } catch (err) { + if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return; + const message = err instanceof Error ? err.message : "Failed to load session"; + const normalized = message.toLowerCase(); + if (normalized.includes("session not found") || normalized.includes("http 404")) { + if (!hasLoadedSessionRef.current) setSessionMissing(true); + setLoading(false); + return; + } + console.error("Failed to fetch session:", err); + if (!hasLoadedSessionRef.current) { + setRouteError(err instanceof Error ? err : new Error("Failed to load session")); + } + } finally { + fetchingSessionRef.current = false; + if (sessionFetchControllerRef.current === controller) + sessionFetchControllerRef.current = null; + if (!controller.signal.aborted || hasLoadedSessionRef.current) { + setLoading(false); + } else if (mountedRef.current) { + // Aborted before any session was loaded and the component is still + // mounted — React Strict Mode fired the cleanup between mount 1 and + // mount 2, aborting the first fetch. Mount 2's fetchSession() was + // blocked by fetchingSessionRef (not yet reset). Retry immediately + // now that the ref is clear. mountedRef guards against the navigation- + // away case where the component is genuinely unmounted and we should + // not start a new request. + void fetchSession(); + } + } + }, [id]); + + const fetchProjectSessions = useCallback(async () => { + if (fetchingProjectSessionsRef.current) return; + const projectId = sessionProjectIdRef.current; + if (!projectId) return; + const isOrchestrator = sessionIsOrchestratorRef.current; + const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`; + if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return; + fetchingProjectSessionsRef.current = true; + const controller = new AbortController(); + projectSessionsFetchControllerRef.current = controller; + try { + const query = isOrchestrator + ? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true` + : `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`; + const body = await fetchJsonWithTimeout(query, { + signal: controller.signal, + timeoutMs: PROJECT_SESSIONS_FETCH_TIMEOUT_MS, + timeoutMessage: `Project sessions request timed out after ${PROJECT_SESSIONS_FETCH_TIMEOUT_MS}ms`, + }); + const sessions = body.sessions ?? []; + const orchestratorId = + body.orchestratorId ?? + body.orchestrators?.find((o) => o.projectId === projectId)?.id ?? + null; + setProjectOrchestratorId((current) => + current === orchestratorId ? current : orchestratorId, + ); + + if (!isOrchestrator) { + resolvedProjectSessionsKeyRef.current = projectSessionsKey; + return; + } + + const counts: ZoneCounts = { + merge: 0, + respond: 0, + review: 0, + pending: 0, + working: 0, + done: 0, + }; + const allPfxs = [...prefixByProjectRef.current.values()]; + for (const s of sessions) { + if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPfxs)) { + const level = getAttentionLevel(s); + if (level === "action") continue; + counts[level]++; + } + } + setZoneCounts(counts); + } catch (err) { + if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return; + console.error("Failed to fetch project sessions:", err); + } finally { + fetchingProjectSessionsRef.current = false; + if (projectSessionsFetchControllerRef.current === controller) + projectSessionsFetchControllerRef.current = null; + } + }, []); + + useEffect(() => { + void Promise.all([fetchProjects(), fetchSession()]); + }, [fetchProjects, fetchSession]); + + useEffect(() => { + if (!sessionProjectId) return; + void fetchProjectSessions(); + }, [fetchProjectSessions, sessionIsOrchestrator, sessionProjectId]); + + useEffect(() => { + const interval = setInterval(() => { + void fetchSession(); + void fetchProjectSessions(); + }, SESSION_PAGE_REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchSession, fetchProjectSessions]); + + useEffect(() => { + pageUnloadingRef.current = false; + const mark = () => { + pageUnloadingRef.current = true; + }; + window.addEventListener("pagehide", mark); + window.addEventListener("beforeunload", mark); + return () => { + window.removeEventListener("pagehide", mark); + window.removeEventListener("beforeunload", mark); + }; + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + return () => { + sessionFetchControllerRef.current?.abort(); + projectSessionsFetchControllerRef.current?.abort(); + }; + }, []); + + if (loading) return
; + + if (sessionMissing) { + return ( +
+
+ void fetchSession() }} + compact + chrome="card" + /> +
+
+ ); + } + + if (routeError) { + return ( +
+
+ { + setRouteError(null); + setSessionMissing(false); + setLoading(true); + void Promise.all([fetchProjects(), fetchSession()]); + }, + }} + secondaryAction={{ + label: "Back to dashboard", + href: session?.projectId ? `/projects/${session.projectId}` : "/", + }} + error={routeError} + compact + chrome="card" + /> +
+
+ ); + } + + if (!session) { + return ( +
+
+ void fetchSession() }} + secondaryAction={{ + label: "Back to dashboard", + href: expectedProjectId ? `/projects/${expectedProjectId}` : "/", + }} + compact + chrome="card" + /> +
+
+ ); + } + + return ( + + ); +} diff --git a/packages/web/src/app/projects/layout.tsx b/packages/web/src/app/projects/layout.tsx new file mode 100644 index 000000000..91140bcd1 --- /dev/null +++ b/packages/web/src/app/projects/layout.tsx @@ -0,0 +1,19 @@ +import { getDashboardPageData } from "@/lib/dashboard-page-data"; +import { ProjectLayoutClient } from "./[projectId]/project-layout-client"; +import type { ReactNode } from "react"; + +export const dynamic = "force-dynamic"; + +export default async function ProjectLayout({ children }: { children: ReactNode }) { + const pageData = await getDashboardPageData("all"); + + return ( + + {children} + + ); +} diff --git a/packages/web/src/app/review/page.tsx b/packages/web/src/app/review/page.tsx new file mode 100644 index 000000000..e1b7af9d6 --- /dev/null +++ b/packages/web/src/app/review/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { ReviewDashboard } from "@/components/ReviewDashboard"; +import { + getReviewPageData, + getReviewProjectName, + resolveReviewProjectFilter, +} from "@/lib/review-page-data"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(props: { + searchParams: Promise<{ project?: string }>; +}): Promise { + const searchParams = await props.searchParams; + const projectFilter = resolveReviewProjectFilter(searchParams.project); + const projectName = getReviewProjectName(projectFilter); + return { title: { absolute: `ao | ${projectName} Reviews` } }; +} + +export default async function ReviewRoute(props: { searchParams: Promise<{ project?: string }> }) { + const searchParams = await props.searchParams; + const projectFilter = resolveReviewProjectFilter(searchParams.project); + const pageData = await getReviewPageData(projectFilter); + + return ( + + ); +} diff --git a/packages/web/src/app/reviews/page.tsx b/packages/web/src/app/reviews/page.tsx new file mode 100644 index 000000000..090a5b64d --- /dev/null +++ b/packages/web/src/app/reviews/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function ReviewsAliasRoute(props: { + searchParams: Promise<{ project?: string }>; +}) { + const searchParams = await props.searchParams; + const suffix = searchParams.project ? `?project=${encodeURIComponent(searchParams.project)}` : ""; + redirect(`/review${suffix}`); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index b6f405c02..b7e7b87d1 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -149,20 +149,20 @@ describe("SessionPage project polling", () => { expect(fetch).toHaveBeenCalledWith( "/api/projects", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions/worker-1", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions?fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); expect( @@ -258,7 +258,7 @@ describe("SessionPage project polling", () => { expect(fetch).toHaveBeenCalledWith( "/api/sessions?project=my-app&fresh=true", - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.any(Object), ); }); @@ -291,17 +291,12 @@ describe("SessionPage project polling", () => { const { default: SessionPage } = await import("./page"); - render( - - - , - ); + render(); await flushAsyncWork(); expect(screen.getByText("Session not found")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument(); expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument(); - expect(screen.getByTestId("route-error")).toHaveTextContent("NEXT_NOT_FOUND"); }); it("renders an inline error state instead of throwing the route away", async () => { @@ -336,7 +331,7 @@ describe("SessionPage project polling", () => { expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument(); }); - it("times out a stuck session fetch and replaces the infinite loader with an error state", async () => { + it("keeps retrying after a stuck session fetch times out before showing an error state", async () => { global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url === "/api/projects") { @@ -377,12 +372,30 @@ describe("SessionPage project polling", () => { }); await flushAsyncWork(); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + await flushAsyncWork(); + + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(12_000); + }); + await flushAsyncWork(); + expect(screen.getByText("Failed to load session")).toBeInTheDocument(); expect(screen.getByText(/taking too long/i)).toBeInTheDocument(); expect(screen.queryByText("Loading session…")).not.toBeInTheDocument(); }); - it("shows a recoverable unavailable state when the first session request aborts", async () => { + it("does not show the route error after the first aborted session request", async () => { + let sessionFetches = 0; + global.fetch = vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url === "/api/projects") { @@ -396,6 +409,7 @@ describe("SessionPage project polling", () => { } if (url === "/api/sessions/worker-1") { + sessionFetches += 1; return Promise.reject(new DOMException("Aborted", "AbortError")); } @@ -415,83 +429,27 @@ describe("SessionPage project polling", () => { render(); await flushAsyncWork(); - expect(screen.getByText("Session unavailable")).toBeInTheDocument(); - expect(screen.getByText(/backend has not returned this session yet/i)).toBeInTheDocument(); - expect(screen.queryByText("Loading session…")).not.toBeInTheDocument(); - }); - - it("marks sidebar data as loading until the sessions list resolves", async () => { - const workerSession = makeWorkerSession(); - let resolveSidebarSessions: ((value: Response) => void) | null = null; - - global.fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response); - } - - if (url === "/api/sessions/worker-1") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => workerSession, - } as Response); - } - - if (url === "/api/sessions?fresh=true") { - return new Promise((resolve) => { - resolveSidebarSessions = resolve; - }); - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response); - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - const latestBeforeSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestBeforeSidebarResolve.sidebarLoading).toBe(true); - expect(latestBeforeSidebarResolve.sidebarSessions).toBeNull(); + expect(sessionFetches).toBe(1); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); await act(async () => { - resolveSidebarSessions?.({ - ok: true, - status: 200, - json: async () => ({ sessions: [workerSession] }), - } as Response); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(3_000); }); + await flushAsyncWork(); - const latestAfterSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; + expect(sessionFetches).toBeGreaterThanOrEqual(3); + expect(screen.queryByText("Failed to load session")).not.toBeInTheDocument(); - expect(latestAfterSidebarResolve.sidebarLoading).toBe(false); - expect(latestAfterSidebarResolve.sidebarSessions).toEqual([workerSession]); + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000); + }); + await flushAsyncWork(); + + expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0); }); + it("revalidates projects and sidebar sessions on remount even when cache exists", async () => { const workerSession = makeWorkerSession(); const fetchMock = vi.fn(async (input: RequestInfo | URL) => { @@ -608,145 +566,7 @@ describe("SessionPage project polling", () => { ); }); - it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => { - const workerSession = makeWorkerSession(); - global.fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return { - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response; - } - - if (url === "/api/sessions/worker-1") { - return { - ok: true, - status: 200, - json: async () => workerSession, - } as Response; - } - - if (url === "/api/sessions?fresh=true") { - return { - ok: false, - status: 500, - json: async () => ({}), - } as Response; - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return { - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - const latestProps = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarError?: boolean; - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestProps.sidebarLoading).toBe(false); - expect(latestProps.sidebarError).toBe(true); - expect(latestProps.sidebarSessions).toEqual([]); - }); - - it("applies mux snapshots that arrive before the initial sidebar fetch resolves", async () => { - const workerSession = makeWorkerSession(); - const muxPatchedLastActivityAt = "2026-04-14T12:00:00.000Z"; - let resolveSidebarSessions: ((value: Response) => void) | null = null; - - mockMuxState.current = { - status: "connected", - sessions: [ - { - id: "worker-1", - status: "working", - activity: "ready", - attentionLevel: "pending", - lastActivityAt: muxPatchedLastActivityAt, - }, - ], - }; - - global.fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response); - } - - if (url === "/api/sessions/worker-1") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => workerSession, - } as Response); - } - - if (url === "/api/sessions?fresh=true") { - return new Promise((resolve) => { - resolveSidebarSessions = resolve; - }); - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response); - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - await act(async () => { - resolveSidebarSessions?.({ - ok: true, - status: 200, - json: async () => ({ sessions: [workerSession] }), - } as Response); - await Promise.resolve(); - }); - - const latestProps = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestProps.sidebarSessions).toEqual([ - { - ...workerSession, - activity: "ready", - lastActivityAt: muxPatchedLastActivityAt, - }, - ]); - }); it("redirects the legacy session URL to the project-scoped route for clean projects", async () => { mockPathname = "/sessions/worker-1"; diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index cbd7efb63..a69d80e9c 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -5,10 +5,7 @@ import { useParams, usePathname, useRouter } from "next/navigation"; import { ACTIVITY_STATE, SESSION_STATUS, isOrchestratorSession } from "@aoagents/ao-core/types"; import { SessionDetail } from "@/components/SessionDetail"; import { ErrorDisplay } from "@/components/ErrorDisplay"; -import { - ProjectSidebar, - type ProjectSidebarOrchestrator, -} from "@/components/ProjectSidebar"; +import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar"; import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery"; import { type DashboardSession, @@ -84,6 +81,9 @@ let cachedProjects: ProjectInfo[] | null = null; let cachedSidebarSessions: DashboardSession[] | null = null; const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000; const SESSION_FETCH_TIMEOUT_MS = 8000; +const SESSION_LOAD_MAX_CONSECUTIVE_FAILURES = 4; +const SESSION_LOAD_MAX_RETRY_ELAPSED_MS = 30_000; +const SESSION_LOAD_RETRY_BACKOFF_MS = [1_000, 2_000, 4_000] as const; const PROJECT_SIDEBAR_FETCH_TIMEOUT_MS = 5000; const PROJECTS_FETCH_TIMEOUT_MS = 5000; const validSessionStatuses = new Set(Object.values(SESSION_STATUS)); @@ -136,6 +136,19 @@ function isAbortLikeError(error: unknown): boolean { return false; } +function isTransientSessionLoadError(error: unknown): boolean { + if (isAbortLikeError(error)) return true; + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("timed out") || + message.includes("network") || + message.includes("failed to fetch") + ); + } + return false; +} + function getSessionLoadErrorMessage(error: Error): string { const normalized = error.message.toLowerCase(); if (normalized.includes("timed out")) { @@ -418,6 +431,22 @@ export default function SessionPage() { const projectSessionsFetchControllerRef = useRef(null); const sidebarFetchControllerRef = useRef(null); const pageUnloadingRef = useRef(false); + const sessionLoadFailureCountRef = useRef(0); + const sessionLoadFirstFailureAtRef = useRef(null); + const sessionLoadRetryTimerRef = useRef | null>(null); + + const clearSessionLoadRetry = useCallback(() => { + if (sessionLoadRetryTimerRef.current) { + clearTimeout(sessionLoadRetryTimerRef.current); + sessionLoadRetryTimerRef.current = null; + } + }, []); + + const resetSessionLoadFailures = useCallback(() => { + sessionLoadFailureCountRef.current = 0; + sessionLoadFirstFailureAtRef.current = null; + clearSessionLoadRetry(); + }, [clearSessionLoadRetry]); // Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map useEffect(() => { @@ -497,6 +526,7 @@ export default function SessionPage() { fetchingSessionRef.current = true; const controller = new AbortController(); sessionFetchControllerRef.current = controller; + let keepLoadingForRetry = false; try { const data = await fetchJsonWithTimeout( `/api/sessions/${encodeURIComponent(id)}`, @@ -510,8 +540,9 @@ export default function SessionPage() { setRouteError(null); setSessionMissing(false); hasLoadedSessionRef.current = true; + resetSessionLoadFailures(); } catch (err) { - if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) { + if (pageUnloadingRef.current || controller.signal.aborted) { return; } const message = err instanceof Error ? err.message : "Failed to load session"; @@ -524,20 +555,55 @@ export default function SessionPage() { setSessionMissing(true); } setLoading(false); + resetSessionLoadFailures(); return; } + + if (!hasLoadedSessionRef.current && isTransientSessionLoadError(err)) { + const failureCount = sessionLoadFailureCountRef.current + 1; + sessionLoadFailureCountRef.current = failureCount; + sessionLoadFirstFailureAtRef.current ??= Date.now(); + const elapsedMs = Date.now() - sessionLoadFirstFailureAtRef.current; + const shouldKeepRetrying = + failureCount < SESSION_LOAD_MAX_CONSECUTIVE_FAILURES && + elapsedMs < SESSION_LOAD_MAX_RETRY_ELAPSED_MS; + + if (shouldKeepRetrying) { + const delay = + SESSION_LOAD_RETRY_BACKOFF_MS[ + Math.min(failureCount - 1, SESSION_LOAD_RETRY_BACKOFF_MS.length - 1) + ]; + keepLoadingForRetry = true; + setLoading(true); + console.warn("Session fetch failed transiently; retrying", { + sessionId: id, + failureCount, + retryInMs: delay, + error: err, + }); + clearSessionLoadRetry(); + sessionLoadRetryTimerRef.current = setTimeout(() => { + sessionLoadRetryTimerRef.current = null; + void fetchSession(); + }, delay); + return; + } + } + console.error("Failed to fetch session:", err); if (!hasLoadedSessionRef.current) { setRouteError(err instanceof Error ? err : new Error("Failed to load session")); } } finally { - setLoading(false); + if (!keepLoadingForRetry) { + setLoading(false); + } fetchingSessionRef.current = false; if (sessionFetchControllerRef.current === controller) { sessionFetchControllerRef.current = null; } } - }, [id]); + }, [clearSessionLoadRetry, id, resetSessionLoadFailures]); const fetchProjectSessions = useCallback(async () => { if (fetchingProjectSessionsRef.current) return; @@ -727,11 +793,12 @@ export default function SessionPage() { useEffect(() => { return () => { + clearSessionLoadRetry(); sessionFetchControllerRef.current?.abort(); projectSessionsFetchControllerRef.current?.abort(); sidebarFetchControllerRef.current?.abort(); }; - }, []); + }, [clearSessionLoadRetry]); if (loading) { return ( @@ -816,6 +883,7 @@ export default function SessionPage() { setRouteError(null); setSessionMissing(false); setLoading(true); + resetSessionLoadFailures(); void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]); }, }} @@ -867,11 +935,6 @@ export default function SessionPage() { orchestratorZones={zoneCounts ?? undefined} projectOrchestratorId={projectOrchestratorId} projects={projects} - sidebarSessions={sidebarSessions} - sidebarOrchestrators={sidebarOrchestrators} - sidebarLoading={sidebarSessions === null} - sidebarError={sidebarError} - onRetrySidebar={fetchSidebarSessions} /> ); } diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index e68d4ddaf..422bedd23 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -1,11 +1,7 @@ "use client"; import { memo, useEffect, useState } from "react"; -import { - type DashboardSession, - type AttentionLevel, - isPRMergeReady, -} from "@/lib/types"; +import { type DashboardSession, type AttentionLevel, isPRMergeReady } from "@/lib/types"; import { SessionCard } from "./SessionCard"; import { getSessionTitle } from "@/lib/format"; import { projectSessionPath } from "@/lib/routes"; @@ -17,6 +13,7 @@ interface AttentionZoneProps { onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; + onReview?: (sessionId: string) => Promise | void; /** Accordion mode: whether this section is collapsed (mobile only) */ collapsed?: boolean; /** Accordion mode: called when the header is tapped to toggle */ @@ -82,6 +79,7 @@ function AttentionZoneView({ onKill, onMerge, onRestore, + onReview, collapsed, onToggle, compactMobile, @@ -121,7 +119,9 @@ function AttentionZoneView({ {config.label} {sessions.length} - +
@@ -143,6 +143,7 @@ function AttentionZoneView({ onKill={onKill} onMerge={onMerge} onRestore={onRestore} + onReview={onReview} /> ), )} @@ -187,6 +188,7 @@ function AttentionZoneView({ onKill={onKill} onMerge={onMerge} onRestore={onRestore} + onReview={onReview} /> ))}
@@ -205,6 +207,7 @@ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZon prev.onKill === next.onKill && prev.onMerge === next.onMerge && prev.onRestore === next.onRestore && + prev.onReview === next.onReview && prev.compactMobile === next.compactMobile && prev.onPreview === next.onPreview && prev.resetKey === next.resetKey && @@ -239,11 +242,7 @@ function MobileSessionRow({ aria-label={`Open ${getSessionTitle(session)}`} >
-
@@ -253,7 +252,7 @@ function MobileSessionRow({
diff --git a/packages/web/src/components/CopyDebugBundleButton.tsx b/packages/web/src/components/CopyDebugBundleButton.tsx index a58cc9607..847494c67 100644 --- a/packages/web/src/components/CopyDebugBundleButton.tsx +++ b/packages/web/src/components/CopyDebugBundleButton.tsx @@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps) return ( ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index ac64f2bef..eb86e8b22 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -18,15 +18,17 @@ import { AttentionZone } from "./AttentionZone"; import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { useMuxOptional } from "@/providers/MuxProvider"; -import { ProjectSidebar } from "./ProjectSidebar"; import type { ProjectInfo } from "@/lib/project-name"; import { EmptyState } from "./Skeleton"; import { ToastProvider, useToast } from "./Toast"; import { ConnectionBar } from "./ConnectionBar"; import { UpdateBanner } from "./UpdateBanner"; import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; -import { SidebarContext } from "./workspace/SidebarContext"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; +import { DashboardNotificationButton } from "./DashboardNotificationButton"; +import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext"; +import { ProjectSidebar } from "./ProjectSidebar"; +import { isOrchestratorSession } from "@aoagents/ao-core/types"; +import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes"; import { BottomSheet } from "./BottomSheet"; import { RemoteAccessQR } from "./RemoteAccessQR"; @@ -125,7 +127,7 @@ function DoneCard({ ) : null} {formatRelativeTimeCompact(session.lastActivityAt)} - {canRestore && !isMerged ? ( + {canRestore ? ( +
+ Agent Orchestrator +
+ {showHeaderProjectLabel ? ( + <> +