diff --git a/.changeset/config.json b/.changeset/config.json index 07ccb4343..c8468bd06 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", @@ -35,7 +37,7 @@ ] ], "snapshot": { - "useCalculatedVersionForSnapshots": true, + "useCalculatedVersion": true, "prereleaseTemplate": "{tag}-{commit}" }, "access": "public", diff --git a/.changeset/hotfix-091.md b/.changeset/hotfix-091.md new file mode 100644 index 000000000..9678337e4 --- /dev/null +++ b/.changeset/hotfix-091.md @@ -0,0 +1,34 @@ +--- +"@aoagents/ao": patch +"@aoagents/ao-cli": patch +"@aoagents/ao-core": patch +"@aoagents/ao-web": patch +"@aoagents/ao-notifier-macos": patch +"@aoagents/ao-plugin-agent-aider": patch +"@aoagents/ao-plugin-agent-claude-code": patch +"@aoagents/ao-plugin-agent-codex": patch +"@aoagents/ao-plugin-agent-cursor": patch +"@aoagents/ao-plugin-agent-grok": patch +"@aoagents/ao-plugin-agent-kimicode": patch +"@aoagents/ao-plugin-agent-opencode": patch +"@aoagents/ao-plugin-notifier-composio": patch +"@aoagents/ao-plugin-notifier-dashboard": patch +"@aoagents/ao-plugin-notifier-desktop": patch +"@aoagents/ao-plugin-notifier-discord": patch +"@aoagents/ao-plugin-notifier-openclaw": patch +"@aoagents/ao-plugin-notifier-slack": patch +"@aoagents/ao-plugin-notifier-webhook": patch +"@aoagents/ao-plugin-runtime-process": patch +"@aoagents/ao-plugin-runtime-tmux": patch +"@aoagents/ao-plugin-scm-github": patch +"@aoagents/ao-plugin-scm-gitlab": patch +"@aoagents/ao-plugin-terminal-iterm2": patch +"@aoagents/ao-plugin-terminal-web": patch +"@aoagents/ao-plugin-tracker-github": patch +"@aoagents/ao-plugin-tracker-gitlab": patch +"@aoagents/ao-plugin-tracker-linear": patch +"@aoagents/ao-plugin-workspace-clone": patch +"@aoagents/ao-plugin-workspace-worktree": patch +--- + +Fix canary nightly to include all publishable packages and fix Next.js import.meta.url build path issue diff --git a/.changeset/launch-orchestrator-clean.md b/.changeset/launch-orchestrator-clean.md deleted file mode 100644 index a13c2f708..000000000 --- a/.changeset/launch-orchestrator-clean.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@aoagents/ao-core": minor -"@aoagents/ao-web": minor ---- - -feat: "Launch Orchestrator (clean context)" action on the orchestrator session page - -Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080. diff --git a/.changeset/quiet-sqlite-rebuild.md b/.changeset/quiet-sqlite-rebuild.md deleted file mode 100644 index 79dbfd120..000000000 --- a/.changeset/quiet-sqlite-rebuild.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@aoagents/ao": patch -"@aoagents/ao-core": patch -"@aoagents/ao-cli": patch ---- - -Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic. diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 234c715b6..9cdfe5dce 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -111,7 +111,31 @@ jobs: # create a minimal one. `changeset version --snapshot` consumes and # deletes it, so no cleanup is needed. if [ -z "$(ls .changeset/*.md 2>/dev/null | grep -vi 'README')" ]; then - printf -- '---\n"@aoagents/ao": patch\n---\n\nchore: canary build\n' > .changeset/canary-temp.md + node << 'SCRIPT' + const fs = require('fs'); + const pkgs = []; + + const addPackage = (m) => { + if (!fs.existsSync(m)) return; + const d = JSON.parse(fs.readFileSync(m, 'utf8')); + if (!d.private && d.publishConfig?.access === 'public') pkgs.push(d.name); + }; + + const scanPackages = (dir) => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) addPackage(`${dir}/${entry.name}/package.json`); + } + }; + + scanPackages('packages'); + scanPackages('packages/plugins'); + + let body = '---\n'; + pkgs.forEach(p => body += '"' + p + '": patch\n'); + body += '---\n\nchore: canary build\n'; + fs.writeFileSync('.changeset/canary-temp.md', body); + SCRIPT fi pnpm changeset version --snapshot nightly env: 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 731ee041f..69dc6b580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,6 +210,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work ### ao start - Registers in `running.json` (PID, port, projects) - Offers to restore sessions from `last-stop.json` — includes cross-project sessions via `otherProjects` field +- `ao start --restore` restores `last-stop.json` without prompting; `ao start --no-restore` skips restore - **Ctrl+C performs full graceful shutdown** (same as ao stop): kills all sessions, writes last-stop state, unregisters from running.json. 10s hard timeout guarantees exit. ### ao stop @@ -218,6 +219,10 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - Always loads global config (`~/.agent-orchestrator/config.yaml`) to see all projects — local config only has the cwd project - Records `LastStopState` with `otherProjects` field for cross-project session restore +### ao update +- For package-manager installs, `ao update` pauses a running AO via `ao stop --yes`, runs the global package update, verifies `ao --version`, then restarts with `ao start --restore` (or `--no-restore` if requested) +- Failed package-manager updates must report that AO was not updated, include actionable remediation, and restart the previous installation if AO was paused + ### Dashboard sidebar - Sidebar always shows sessions from ALL projects regardless of which project page is active - `useSessionEvents` in Dashboard.tsx is called without project filter — sidebar gets unscoped sessions diff --git a/SETUP.md b/SETUP.md index 81782df10..08402f832 100644 --- a/SETUP.md +++ b/SETUP.md @@ -59,6 +59,13 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches - Create incoming webhook: https://api.slack.com/messaging/webhooks - Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."` +- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik) + - Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard + - All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:` + - Example: `export AO_PUBLIC_URL="https://ao.example.com"` + - When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`. + - **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side. + ## Installation ### Install via npm (recommended) 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/eslint.config.js b/eslint.config.js index c6dcc531e..47a9e06ba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -57,6 +57,21 @@ export default tseslint.config( "no-debugger": "error", "no-duplicate-imports": "error", "no-template-curly-in-string": "warn", + "no-restricted-syntax": [ + "error", + { + selector: + "CallExpression[callee.name='createInterface'] Property[key.name='input'] > CallExpression[callee.name='createReadStream']", + message: + "Do not pass createReadStream() inline to createInterface(); keep the stream in a variable and close/destroy it in a finally block.", + }, + { + selector: + "CallExpression[callee.name='createInterface'] Property[key.name='input'] > CallExpression[callee.property.name='createReadStream']", + message: + "Do not pass createReadStream() inline to createInterface(); keep the stream in a variable and close/destroy it in a finally block.", + }, + ], "prefer-const": "error", "no-var": "error", eqeqeq: ["error", "always"], diff --git a/packages/ao/CHANGELOG.md b/packages/ao/CHANGELOG.md index 8c7303359..b414c1beb 100644 --- a/packages/ao/CHANGELOG.md +++ b/packages/ao/CHANGELOG.md @@ -1,5 +1,17 @@ # @aoagents/ao +## 0.9.0 + +### Patch Changes + +- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic. +- Updated dependencies [6d48022] +- Updated dependencies [ecdf0c7] +- Updated dependencies [fcedb25] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-cli@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/ao/README.md b/packages/ao/README.md new file mode 100644 index 000000000..ad402d540 --- /dev/null +++ b/packages/ao/README.md @@ -0,0 +1,93 @@ +
+ +# Agent Orchestrator (`ao`) + +**The orchestration layer for parallel AI coding agents.** + +[![npm version](https://img.shields.io/npm/v/%40aoagents%2Fao?style=flat-square)](https://www.npmjs.com/package/@aoagents/ao) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/blob/main/LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/ComposioHQ/agent-orchestrator?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator) +[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/UZv7JjxbwG) + +Agent Orchestrator + +
+ +Spawn parallel AI coding agents, each in its own git worktree, on a single machine. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise the whole fleet from one dashboard. + +**Agent-agnostic** (Claude Code, Codex, Aider, Cursor, OpenCode) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear, GitLab) + +## Install + +```bash +npm install -g @aoagents/ao +``` + +> **Nightly builds** (latest `main`): `npm install -g @aoagents/ao@nightly` — back to stable with `@latest`. + +**Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), the [`gh` CLI](https://cli.github.com), and at least one coding-agent CLI (e.g. [Claude Code](https://www.anthropic.com/claude-code)). + +- **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — `brew install tmux` or `sudo apt install tmux`. +- **Windows:** PowerShell 7+ recommended; tmux is **not** required (AO uses native ConPTY via the `process` runtime). + +## Quick start + +Point it at any repo — it clones, configures, and launches the dashboard in one command: + +```bash +ao start https://github.com/your-org/your-repo +``` + +Or from inside an existing local repo: + +```bash +cd ~/your-project && ao start +``` + +The dashboard opens at `http://localhost:3000` and an orchestrator agent starts managing your project. Add more repos any time: + +```bash +ao start ~/path/to/another-repo +``` + +You don't need to learn the CLI — the dashboard and the orchestrator agent drive everything. (Individual `ao` commands are documented in the [CLI Reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md) and used internally by the orchestrator.) + +## How it works + +1. **You start** — `ao start` launches the dashboard and an orchestrator agent. +2. **Orchestrator spawns workers** — each issue gets its own agent in an isolated git worktree and branch. +3. **Agents work autonomously** — they read code, write tests, and open PRs. +4. **Reactions handle feedback** — CI failures and review comments are routed back to the responsible agent automatically. +5. **You review and merge** — you're pulled in only when human judgment is needed. + +## Pluggable by design + +Seven plugin slots; the lifecycle state machine stays in core: + +| Slot | Default | Alternatives | +| --- | --- | --- | +| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker | +| Agent | claude-code | codex, aider, cursor, opencode, kimicode | +| Workspace | worktree | clone | +| Tracker | github | linear, gitlab | +| SCM | github | gitlab | +| Notifier | desktop | slack, discord, composio, webhook, openclaw | +| Terminal | iterm2 | web | + +## Why Agent Orchestrator? + +Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem: creating branches, detecting stuck agents, reading CI failures, forwarding review comments, tracking which PRs are ready, and cleaning up afterward. + +Agent Orchestrator handles the isolation, feedback routing, and status tracking. You `ao start` and walk away — then review PRs and make decisions. The rest is automated. + +## Documentation + +- 📖 [Project README & overview](https://github.com/ComposioHQ/agent-orchestrator) +- 🛠️ [Setup guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/SETUP.md) — install, configuration, troubleshooting +- ⌨️ [CLI reference](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/CLI.md) +- 🧩 [Development & plugin guide](https://github.com/ComposioHQ/agent-orchestrator/blob/main/docs/DEVELOPMENT.md) +- 💬 [Discord community](https://discord.gg/UZv7JjxbwG) + +## License + +MIT © [ComposioHQ](https://github.com/ComposioHQ/agent-orchestrator) diff --git a/packages/ao/package.json b/packages/ao/package.json index ab301e4e8..75b8e4c3d 100644 --- a/packages/ao/package.json +++ b/packages/ao/package.json @@ -1,7 +1,22 @@ { "name": "@aoagents/ao", - "version": "0.8.0", + "version": "0.9.0", "description": "Orchestrate parallel AI coding agents — global CLI wrapper", + "keywords": [ + "ai", + "ai-agents", + "coding-agent", + "agent-orchestration", + "orchestrator", + "claude-code", + "codex", + "aider", + "cli", + "automation", + "devtools", + "parallel-agents", + "git-worktree" + ], "license": "MIT", "type": "module", "bin": { @@ -11,7 +26,8 @@ "postinstall": "node bin/postinstall.js" }, "files": [ - "bin" + "bin", + "README.md" ], "repository": { "type": "git", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index eab75ff1e..60abe6f05 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,58 @@ # @aoagents/ao-cli +## 0.9.0 + +### Minor Changes + +- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths. +- ecdf0c7: Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host. + + Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains. + +### Patch Changes + +- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI. +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [8c71bde] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [ee3fb5d] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [6d48022] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] +- Updated dependencies [07c9099] + - @aoagents/ao-core@0.9.0 + - @aoagents/ao-web@0.9.0 + - @aoagents/ao-plugin-agent-claude-code@0.9.0 + - @aoagents/ao-plugin-tracker-linear@0.9.0 + - @aoagents/ao-notifier-macos@0.9.0 + - @aoagents/ao-plugin-notifier-composio@0.9.0 + - @aoagents/ao-plugin-notifier-dashboard@0.9.0 + - @aoagents/ao-plugin-notifier-desktop@0.9.0 + - @aoagents/ao-plugin-notifier-discord@0.9.0 + - @aoagents/ao-plugin-notifier-openclaw@0.9.0 + - @aoagents/ao-plugin-notifier-slack@0.9.0 + - @aoagents/ao-plugin-agent-aider@0.9.0 + - @aoagents/ao-plugin-agent-codex@0.9.0 + - @aoagents/ao-plugin-agent-cursor@0.9.0 + - @aoagents/ao-plugin-agent-grok@0.1.1 + - @aoagents/ao-plugin-agent-kimicode@0.9.0 + - @aoagents/ao-plugin-agent-opencode@0.9.0 + - @aoagents/ao-plugin-notifier-webhook@0.9.0 + - @aoagents/ao-plugin-runtime-process@0.9.0 + - @aoagents/ao-plugin-runtime-tmux@0.9.0 + - @aoagents/ao-plugin-scm-github@0.9.0 + - @aoagents/ao-plugin-terminal-iterm2@0.9.0 + - @aoagents/ao-plugin-terminal-web@0.9.0 + - @aoagents/ao-plugin-tracker-github@0.9.0 + - @aoagents/ao-plugin-workspace-clone@0.9.0 + - @aoagents/ao-plugin-workspace-worktree@0.9.0 + ## 0.8.0 ### Patch Changes 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..964f0a674 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", @@ -1742,6 +1810,46 @@ describe("stop command", () => { expect(output).toContain("app-orchestrator-3"); }); + it("does not show the project picker for no-args ao stop across multiple projects", async () => { + mockConfigRef.current = makeConfig({ + "project-1": makeProject({ name: "Project 1", sessionPrefix: "p1" }), + "project-2": makeProject({ name: "Project 2", sessionPrefix: "p2" }), + }); + mockSessionManager.list.mockResolvedValue([ + { + id: "p1-1", + projectId: "project-1", + status: "working", + activity: "active", + metadata: {}, + lastActivityAt: new Date(), + runtimeHandle: { id: "tmux-1" }, + }, + { + id: "p2-1", + projectId: "project-2", + status: "working", + activity: "active", + metadata: {}, + lastActivityAt: new Date(), + runtimeHandle: { id: "tmux-2" }, + }, + ]); + mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false }); + mockPromptConfirm.mockResolvedValue(true); + + await program.parseAsync(["node", "test", "stop"]); + + expect(mockPromptSelect).not.toHaveBeenCalledWith( + expect.stringContaining("Choose project to stop"), + expect.anything(), + expect.anything(), + ); + expect(mockPromptConfirm).toHaveBeenCalledWith("Stop AO and 2 active session(s)?", false); + expect(mockSessionManager.kill).toHaveBeenCalledWith("p1-1", { purgeOpenCode: false }); + expect(mockSessionManager.kill).toHaveBeenCalledWith("p2-1", { purgeOpenCode: false }); + }); + it("kills the most-recently-active orchestrator when multiple exist", async () => { mockConfigRef.current = makeConfig({ "my-app": makeProject() }); const now = Date.now(); 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__/commands/update.test.ts b/packages/cli/__tests__/commands/update.test.ts index 72cb31047..843bcc1bb 100644 --- a/packages/cli/__tests__/commands/update.test.ts +++ b/packages/cli/__tests__/commands/update.test.ts @@ -5,9 +5,7 @@ import { Command } from "commander"; // Mocks // --------------------------------------------------------------------------- -const { - mockRunRepoScript, -} = vi.hoisted(() => ({ +const { mockRunRepoScript } = vi.hoisted(() => ({ mockRunRepoScript: vi.fn(), })); @@ -55,8 +53,8 @@ vi.mock("../../src/lib/update-check.js", () => ({ isManualOnlyInstall: (m: string) => m === "homebrew", })); -// Stub the active-session guard's dependencies so handlers don't try to load -// real config / spawn plugins. Default: no sessions, so the guard passes. +// Stub the update lifecycle planner's dependencies so handlers don't try to +// load real config / spawn plugins. Default: no sessions, so no stop is needed. const { mockSessions } = vi.hoisted(() => ({ mockSessions: { value: [] as Array<{ id: string; status: string }> }, })); @@ -84,8 +82,7 @@ vi.mock("@aoagents/ao-core", async () => { loadConfig: (...args: unknown[]) => mockLoadConfig(...args), loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args), getGlobalConfigPath: () => "/tmp/test-global-config.yaml", - isCanonicalGlobalConfigPath: (p: string | undefined) => - p === "/tmp/test-global-config.yaml", + isCanonicalGlobalConfigPath: (p: string | undefined) => p === "/tmp/test-global-config.yaml", isWindows: () => mockIsWindows(), }; }); @@ -98,10 +95,8 @@ vi.mock("node:fs", async () => { }; }); -// running.json is the live signal: ensureNoActiveSessions now consults -// `getRunning()` before falling back to the global registry. Default to -// "no daemon running" so the existing global-config-driven tests keep -// exercising the fallback path. Per-test overrides simulate a live daemon. +// running.json is the live signal for update stop/start orchestration. Default +// to "no daemon running"; per-test overrides simulate a live daemon. const { mockGetRunning } = vi.hoisted(() => ({ mockGetRunning: vi.fn<() => Promise>(async () => null), })); @@ -147,9 +142,28 @@ function makeNpmUpdateInfo(overrides = {}) { }; } -function createMockChild(exitCode: number | null, signal?: NodeJS.Signals) { +function createMockChild( + exitCode: number | null, + signal?: NodeJS.Signals, + output: { stdout?: string; stderr?: string } = { stdout: "0.3.0\n" }, +) { const child = new EventEmitter(); - setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0); + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + Object.assign(child, { stdout, stderr }); + const emitResult = () => { + setTimeout(() => { + if (output.stdout) stdout.emit("data", Buffer.from(output.stdout)); + if (output.stderr) stderr.emit("data", Buffer.from(output.stderr)); + child.emit("exit", exitCode, signal ?? null); + }, 0); + }; + const originalOn = child.on.bind(child); + child.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => { + const result = originalOn(event, listener); + if (event === "exit") emitResult(); + return result; + }) as EventEmitter["on"]; return child; } @@ -166,7 +180,9 @@ describe("update command", () => { mockRunRepoScript.mockResolvedValue(0); mockDetectInstallMethod.mockReturnValue("git"); mockCheckForUpdate.mockReset(); - mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" })); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ installMethod: "git", recommendedCommand: "ao update" }), + ); mockInvalidateCache.mockReset(); mockPromptConfirm.mockReset(); mockPromptConfirm.mockResolvedValue(false); @@ -234,9 +250,9 @@ describe("update command", () => { it("rejects --smoke-only on npm installs with an actionable message", async () => { mockDetectInstallMethod.mockReturnValue("npm-global"); const errSpy = vi.mocked(console.error); - await expect( - program.parseAsync(["node", "test", "update", "--smoke-only"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update", "--smoke-only"])).rejects.toThrow( + "process.exit(1)", + ); const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); expect(messages).toMatch(/--smoke-only only applies to git installs/); }); @@ -306,9 +322,9 @@ describe("update command", () => { new Error("Script not found: ao-update.sh. Expected at: /tmp/ao-update.sh"), ); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); expect(mockSpawn).not.toHaveBeenCalled(); expect(mockCheckForUpdate).not.toHaveBeenCalled(); @@ -353,7 +369,9 @@ describe("update command", () => { }); it("prints already up to date when not outdated", async () => { - mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" })); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ isOutdated: false, latestVersion: "0.2.2", currentVersion: "0.2.2" }), + ); const logSpy = vi.mocked(console.log); await program.parseAsync(["node", "test", "update"]); @@ -365,9 +383,9 @@ describe("update command", () => { makeNpmUpdateInfo({ latestVersion: null, isOutdated: false }), ); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); expect(vi.mocked(console.error)).toHaveBeenCalledWith( expect.stringContaining("Could not reach npm registry"), ); @@ -375,9 +393,7 @@ describe("update command", () => { it("forces a fresh registry fetch", async () => { await program.parseAsync(["node", "test", "update"]); - expect(mockCheckForUpdate).toHaveBeenCalledWith( - expect.objectContaining({ force: true }), - ); + expect(mockCheckForUpdate).toHaveBeenCalledWith(expect.objectContaining({ force: true })); }); it("prints command and exits cleanly in non-TTY mode without prompting", async () => { @@ -400,7 +416,11 @@ describe("update command", () => { await program.parseAsync(["node", "test", "update"]); - expect(mockSpawn).toHaveBeenCalledWith("npm", expect.arrayContaining(["install"]), expect.anything()); + expect(mockSpawn).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["install"]), + expect.anything(), + ); expect(mockInvalidateCache).toHaveBeenCalled(); }); @@ -410,9 +430,9 @@ describe("update command", () => { mockPromptConfirm.mockResolvedValue(true); mockSpawn.mockReturnValue(createMockChild(1)); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); expect(mockInvalidateCache).not.toHaveBeenCalled(); }); @@ -438,9 +458,9 @@ describe("update command", () => { mockPromptConfirm.mockResolvedValue(true); mockSpawn.mockReturnValue(createMockChild(null, "SIGTERM")); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); expect(vi.mocked(console.error)).not.toHaveBeenCalledWith( expect.stringContaining("exited with code null"), @@ -448,18 +468,26 @@ describe("update command", () => { expect(mockInvalidateCache).not.toHaveBeenCalled(); }); - it("handles spawn error (e.g. npm not found)", async () => { + it("handles spawn error (e.g. npm not found) with a friendly update failure", async () => { Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); mockPromptConfirm.mockResolvedValue(true); const child = new EventEmitter(); + Object.assign(child, { stdout: new EventEmitter(), stderr: new EventEmitter() }); mockSpawn.mockReturnValue(child); setTimeout(() => child.emit("error", new Error("ENOENT: npm not found")), 0); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("ENOENT"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(stderr).toContain("AO was not updated. You are still on version 0.2.2."); + expect(stderr).toContain("npm not found"); }); it("does nothing when user declines prompt", async () => { @@ -489,7 +517,9 @@ describe("update command", () => { await program.parseAsync(["node", "test", "update"]); - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Could not detect install method")); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not detect install method"), + ); expect(mockRunRepoScript).not.toHaveBeenCalled(); }); @@ -521,269 +551,157 @@ describe("update command", () => { }); // ----------------------------------------------------------------------- - // Active-session guard (Section C) + // Update lifecycle orchestration (#1972) // ----------------------------------------------------------------------- - describe("active-session guard", () => { + describe("update lifecycle orchestration", () => { beforeEach(() => { - mockDetectInstallMethod.mockReturnValue("npm-global"); - mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); + mockDetectInstallMethod.mockReturnValue("pnpm-global"); + mockResolveUpdateChannel.mockReturnValue("stable"); + mockGetUpdateCommand.mockImplementation((method: string) => { + if (method === "pnpm-global") return "pnpm add -g @aoagents/ao@latest"; + return "npm install -g @aoagents/ao@latest"; + }); + mockCheckForUpdate.mockResolvedValue( + makeNpmUpdateInfo({ + installMethod: "pnpm-global", + recommendedCommand: "pnpm add -g @aoagents/ao@latest", + }), + ); Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - // The guard now ALWAYS loads from global config. Stage a registered - // project so the early-return ("no registry → allow") doesn't fire. + mockGetRunning + .mockResolvedValueOnce({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }) + .mockResolvedValue(null); + mockLoadConfig.mockImplementation((path?: string) => ({ + projects: { "my-app": { path: "/tmp/foo" } }, + configPath: path ?? "/cwd/agent-orchestrator.yaml", + })); + mockSessions.value = [{ id: "feat-1", status: "working", projectId: "my-app" }]; + }); + + it("runs stop → package install → version verification → start/restore when sessions are active", async () => { + mockSpawn.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "ao" && args[0] === "--version") { + return createMockChild(0, undefined, { stdout: "0.3.0\n" }); + } + return createMockChild(0, undefined, { stdout: "" }); + }); + + await program.parseAsync(["node", "test", "update"]); + + expect(mockSpawn).toHaveBeenCalledTimes(4); + expect(mockSpawn.mock.calls[0]).toEqual([ + "ao", + ["stop", "--yes"], + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), + ]); + expect(mockSpawn.mock.calls[1][0]).toBe("pnpm"); + expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]); + expect(mockSpawn.mock.calls[2][0]).toBe("ao"); + expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]); + expect(mockSpawn.mock.calls[3]).toEqual([ + "ao", + ["start", "my-app", "--restore"], + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), + ]); + + const stopOrder = mockSpawn.mock.invocationCallOrder[0]; + const installOrder = mockSpawn.mock.invocationCallOrder[1]; + const startOrder = mockSpawn.mock.invocationCallOrder[3]; + expect(stopOrder).toBeLessThan(installOrder); + expect(installOrder).toBeLessThan(startOrder); + }); + + it("does not stop/start when no daemon or active sessions exist", async () => { + mockGetRunning.mockReset(); + mockGetRunning.mockResolvedValue(null); + mockExistsSync.mockReturnValue(false); + mockSessions.value = []; + mockSpawn.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "ao" && args[0] === "--version") { + return createMockChild(0, undefined, { stdout: "0.3.0\n" }); + } + return createMockChild(0, undefined, { stdout: "" }); + }); + + await program.parseAsync(["node", "test", "update"]); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSpawn.mock.calls[0][0]).toBe("pnpm"); + expect(mockSpawn.mock.calls[1][0]).toBe("ao"); + expect(mockSpawn.mock.calls[1][1]).toEqual(["--version"]); + }); + + it("cleans up orphaned active sessions without starting a daemon that was not running", async () => { + mockGetRunning.mockReset(); + mockGetRunning.mockResolvedValue(null); mockExistsSync.mockReturnValue(true); mockLoadGlobalConfig.mockReturnValue({ projects: { "my-app": { path: "/tmp/foo" } }, }); - mockLoadConfig.mockImplementation((path?: string) => - path - ? { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path } - : { projects: { "my-app": { path: "/tmp/foo" } }, configPath: "/cwd/agent-orchestrator.yaml" }, + mockSessions.value = [{ id: "orphan-1", status: "working", projectId: "my-app" }]; + mockSpawn.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "ao" && args[0] === "stop") { + mockSessions.value = []; + return createMockChild(0, undefined, { stdout: "" }); + } + if (cmd === "ao" && args[0] === "--version") { + return createMockChild(0, undefined, { stdout: "0.3.0\n" }); + } + return createMockChild(0, undefined, { stdout: "" }); + }); + + await program.parseAsync(["node", "test", "update"]); + + expect(mockSpawn.mock.calls[0][0]).toBe("ao"); + expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]); + expect(mockSpawn.mock.calls[0][2]).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), + ); + expect(mockSpawn.mock.calls[1][0]).toBe("pnpm"); + expect(mockSpawn.mock.calls[2][0]).toBe("ao"); + expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]); + expect(mockSpawn.mock.calls.some((call) => call[0] === "ao" && call[1][0] === "start")).toBe( + false, ); }); - it("refuses to install when a session is in 'working'", async () => { - mockSessions.value = [{ id: "feat-1", status: "working" }]; - const errSpy = vi.mocked(console.error); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); - const messages = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(messages).toMatch(/1 session active/); - expect(messages).toMatch(/ao stop/); - expect(mockSpawn).not.toHaveBeenCalled(); - }); - - it.each(["working", "idle", "needs_input", "stuck"])( - "refuses for status %s", - async (status) => { - mockSessions.value = [{ id: "feat-1", status }]; - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); - }, - ); - - it("does NOT refuse for terminal statuses (done, terminated, killed)", async () => { - mockSessions.value = [ - { id: "old-1", status: "done" }, - { id: "old-2", status: "terminated" }, - ]; - mockPromptConfirm.mockResolvedValue(false); // decline, no install - await program.parseAsync(["node", "test", "update"]); - // Reaches the prompt step since the guard passed. - expect(mockPromptConfirm).toHaveBeenCalled(); - }); - - // --------------------------------------------------------------------- - // Global-config layout (review #3 / scope-gap follow-up) - // --------------------------------------------------------------------- - - it("the refusal message lists active sessions from EVERY registered project, not just one (Dhruv proof)", async () => { - // Reviewer challenge: prove loadConfig(globalPath) actually enumerates - // sessions across all registered projects, not just the cwd's project. - // We register proj-a + proj-b in the global config, seed one active - // session in each, and assert BOTH ids appear in the stderr output. - mockLoadConfig.mockImplementation((path?: string) => { - // Mimic buildEffectiveConfigFromGlobalConfigPath: the global path - // returns BOTH projects; project-local would only return one. - if (!path) { - return { projects: { "proj-a": {} }, configPath: "/cwd/agent-orchestrator.yaml" }; + it("honors --no-restore when restarting after update", async () => { + mockSpawn.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "ao" && args[0] === "--version") { + return createMockChild(0, undefined, { stdout: "0.3.0\n" }); } - return { - projects: { - "proj-a": { path: "/repos/a" }, - "proj-b": { path: "/repos/b" }, - }, - configPath: path, - }; + return createMockChild(0, undefined, { stdout: "" }); }); - mockLoadGlobalConfig.mockReturnValue({ - projects: { - "proj-a": { path: "/repos/a" }, - "proj-b": { path: "/repos/b" }, - }, - }); - mockExistsSync.mockReturnValue(true); - // One active session per project. sm.list() is single-call (the SM - // implementation enumerates across all projectIds), so we return both - // sessions in one shot — matching real behavior. `projectId` is - // included so it's visible to anyone reading the refusal output. - mockSessions.value = [ - { id: "proj-a-feat-1", status: "working", projectId: "proj-a" }, - { id: "proj-b-feat-2", status: "needs_input", projectId: "proj-b" }, - ]; - const errSpy = vi.mocked(console.error); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await program.parseAsync(["node", "test", "update", "--no-restore"]); - const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); - // Refusal message reports the correct total count (2, not 1). - expect(stderr).toMatch(/2 sessions active/); - // Both project's session ids appear in the listing. - expect(stderr).toMatch(/proj-a-feat-1/); - expect(stderr).toMatch(/proj-b-feat-2/); - expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockSpawn.mock.calls.at(-1)).toEqual([ + "ao", + ["start", "my-app", "--no-restore"], + expect.objectContaining({ + stdio: "inherit", + env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }), + }), + ]); }); - it("always loads global config (never project-local), so sessions in OTHER projects fire the guard", async () => { - // Simulate running inside a project: project-local loadConfig() would - // succeed and return only THIS project's sessions. The guard must - // ignore it and still consult the global registry, otherwise active - // sessions in other projects get missed and the install would proceed. - mockLoadConfig.mockImplementation((path?: string) => { - if (!path) { - // Project-local: would return only "this-project"'s sessions. - return { projects: { "this-project": {} }, configPath: "/cwd/agent-orchestrator.yaml" }; - } - return { - projects: { - "this-project": { path: "/cwd" }, - "other-project": { path: "/other" }, - }, - configPath: path, - }; - }); - mockLoadGlobalConfig.mockReturnValue({ - projects: { - "this-project": { path: "/cwd" }, - "other-project": { path: "/other" }, - }, - }); - mockExistsSync.mockReturnValue(true); - // Active session lives in the OTHER project — only visible via global. - mockSessions.value = [ - { id: "other-1", status: "working" }, - ]; - - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); - - expect(mockLoadGlobalConfig).toHaveBeenCalled(); - // Critical: we did NOT call the project-local (no-arg) loadConfig path. - const noArgCalls = mockLoadConfig.mock.calls.filter((c) => c.length === 0); - expect(noArgCalls).toHaveLength(0); - expect(mockSpawn).not.toHaveBeenCalled(); - }); - - it("uses the global registry when running outside any project", async () => { - mockLoadConfig.mockImplementation((path?: string) => { - if (!path) throw new Error("no config found"); - return { projects: { "my-app": { path: "/tmp/foo" } }, configPath: path }; - }); - mockLoadGlobalConfig.mockReturnValue({ - projects: { "my-app": { path: "/tmp/foo" } }, - }); - mockExistsSync.mockReturnValue(true); - mockSessions.value = [{ id: "feat-1", status: "working" }]; - - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); - - expect(mockLoadGlobalConfig).toHaveBeenCalled(); - expect(mockSpawn).not.toHaveBeenCalled(); - }); - - it("returns early without building SessionManager when global registry is empty", async () => { - mockLoadConfig.mockImplementation((path?: string) => { - if (!path) throw new Error("no config found"); - return { projects: {}, configPath: path }; - }); - mockLoadGlobalConfig.mockReturnValue({ projects: {} }); - mockExistsSync.mockReturnValue(true); - - // Guard returns true (allow update) without ever calling sm.list(). - // No mockSessions configured, no spawn → confirms we never reached - // SessionManager construction. - mockPromptConfirm.mockResolvedValue(false); // decline soft-install - await program.parseAsync(["node", "test", "update"]); - expect(mockLoadGlobalConfig).toHaveBeenCalled(); - // The decline-prompt path means the guard let us through. - expect(mockPromptConfirm).toHaveBeenCalled(); - }); - - it("returns early without building SessionManager when global config file is missing", async () => { - mockLoadConfig.mockImplementation((path?: string) => { - if (!path) throw new Error("no config found"); - return { projects: {}, configPath: path }; - }); - mockExistsSync.mockReturnValue(false); // no ~/.agent-orchestrator/config.yaml - - mockPromptConfirm.mockResolvedValue(false); - await program.parseAsync(["node", "test", "update"]); - // We didn't even consult loadGlobalConfig — existsSync(globalPath) was false. - expect(mockLoadGlobalConfig).not.toHaveBeenCalled(); - expect(mockPromptConfirm).toHaveBeenCalled(); - }); - - it("refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)", async () => { - // The bypass: user ran `ao start` from a repo with a local - // agent-orchestrator.yaml and no global registration. running.json - // says that project is being polled, sessions live on disk, but the - // global registry is empty. Before this fix, the guard hit the - // "global has no projects → allow" branch and let `ao update` - // clobber the running daemon. - // - // Fix: consult running.json BEFORE falling back to global. When - // running.json has projects, build the SessionManager from - // running.configPath (which is the local project's yaml in this case) - // and enumerate from there. - mockGetRunning.mockResolvedValue({ - pid: 12345, - configPath: "/repos/local-only/agent-orchestrator.yaml", - port: 3000, - startedAt: new Date().toISOString(), - projects: ["local-only"], - }); - // Global registry has no record of `local-only` — this is the bypass - // condition. With the old code, we'd return true here. - mockLoadGlobalConfig.mockReturnValue({ projects: {} }); - // loadConfig with the local configPath returns the local project's - // OrchestratorConfig (project-local schema is auto-wrapped). - mockLoadConfig.mockImplementation((path?: string) => { - if (path === "/repos/local-only/agent-orchestrator.yaml") { - return { - projects: { "local-only": { path: "/repos/local-only" } }, - configPath: path, - }; - } - return { projects: {}, configPath: path ?? "/cwd/agent-orchestrator.yaml" }; - }); - mockSessions.value = [ - { id: "local-feat-1", status: "working", projectId: "local-only" }, - ]; - - const errSpy = vi.mocked(console.error); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); - - const stderr = errSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(stderr).toMatch(/1 session active/); - expect(stderr).toMatch(/local-feat-1/); - // We must have routed through running.configPath, NOT the global path. - expect(mockLoadConfig).toHaveBeenCalledWith("/repos/local-only/agent-orchestrator.yaml"); - expect(mockSpawn).not.toHaveBeenCalled(); - }); - - it("returns true (allows update) when running.json is gone and global is empty", async () => { - // No daemon running, no global projects. Genuinely safe to update. - mockGetRunning.mockResolvedValue(null); - mockExistsSync.mockReturnValue(false); - mockPromptConfirm.mockResolvedValue(false); - await program.parseAsync(["node", "test", "update"]); - expect(mockPromptConfirm).toHaveBeenCalled(); // guard passed → reached prompt - }); - - it("trusts running.json over an inconsistent global config", async () => { - // running.json says project P is being polled. Global config also - // lists P. We should use running.configPath (the live signal), and - // any active session in P fires the guard. + it("aborts before install if ao stop exits 0 but AO still appears active", async () => { mockGetRunning.mockResolvedValue({ pid: 12345, configPath: "/tmp/test-global-config.yaml", @@ -791,23 +709,46 @@ describe("update command", () => { startedAt: new Date().toISOString(), projects: ["my-app"], }); - mockLoadConfig.mockImplementation((path?: string) => ({ - projects: { "my-app": { path: "/tmp/foo" } }, - configPath: path ?? "/cwd/agent-orchestrator.yaml", - })); - mockLoadGlobalConfig.mockReturnValue({ - projects: { "my-app": { path: "/tmp/foo" } }, - }); - mockSessions.value = [{ id: "feat-1", status: "working" }]; + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "" })); - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); - // Because getRunning() returned a daemon, we went straight to its - // configPath — we should NOT have fallen back to loadGlobalConfig. - expect(mockLoadGlobalConfig).not.toHaveBeenCalled(); - expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn.mock.calls[0][0]).toBe("ao"); + expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]); + expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false); + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(stderr).toContain("AO still appears to be running after `ao stop --yes`"); + }); + + it("prints a friendly pnpm diagnostic and npm fallback when pnpm fails", async () => { + mockGetRunning.mockReset(); + mockGetRunning.mockResolvedValue(null); + mockExistsSync.mockReturnValue(false); + mockSpawn.mockReturnValue( + createMockChild(1, undefined, { + stderr: "ERR_PNPM_UNEXPECTED_VIRTUAL_STORE Unexpected virtual store location\n", + }), + ); + + await expect(program.parseAsync(["node", "test", "update"])).rejects.toThrow( + "process.exit(1)", + ); + + const stderr = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); + expect(stderr).toContain("AO was not updated. You are still on version 0.2.2."); + expect(stderr).toContain("pnpm's global store metadata is inconsistent"); + expect(stderr).toContain("You can also try: npm install -g @aoagents/ao@latest"); + expect(stderr).toContain("ERR_PNPM_UNEXPECTED_VIRTUAL_STORE"); + expect(mockInvalidateCache).not.toHaveBeenCalled(); }); }); @@ -874,7 +815,7 @@ describe("update command", () => { }), ); mockPromptConfirm.mockResolvedValue(true); - mockSpawn.mockReturnValue(createMockChild(0)); + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" })); await program.parseAsync(["node", "test", "update"]); @@ -990,7 +931,7 @@ describe("update command", () => { }), ); mockPromptConfirm.mockResolvedValue(true); - mockSpawn.mockReturnValue(createMockChild(0)); + mockSpawn.mockReturnValue(createMockChild(0, undefined, { stdout: "0.5.0-nightly-abc\n" })); await program.parseAsync(["node", "test", "update"]); @@ -1035,9 +976,7 @@ describe("update command", () => { beforeEach(() => { mockDetectInstallMethod.mockReturnValue("npm-global"); mockResolveUpdateChannel.mockReturnValue("stable"); - mockCheckForUpdate.mockResolvedValue( - makeNpmUpdateInfo({ installMethod: "npm-global" }), - ); + mockCheckForUpdate.mockResolvedValue(makeNpmUpdateInfo({ installMethod: "npm-global" })); mockExistsSync.mockReturnValue(true); mockLoadGlobalConfig.mockReturnValue({ projects: { "my-app": { path: "/tmp/foo" } }, @@ -1069,7 +1008,11 @@ describe("update command", () => { // would run. Asserting spawn was called proves the install actually // happens in the API-invoked path. await program.parseAsync(["node", "test", "update"]); - expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn).toHaveBeenCalledWith( + "npm", + expect.arrayContaining(["install"]), + expect.anything(), + ); // And without a TTY, we MUST NOT have prompted — that would hang the // detached child forever. expect(mockPromptConfirm).not.toHaveBeenCalled(); @@ -1084,12 +1027,25 @@ describe("update command", () => { expect(mockSpawn).not.toHaveBeenCalled(); }); - it("still refuses on active sessions even when API-invoked (the API's own guard isn't a single point of trust)", async () => { + it("orchestrates stop/install/verify/start when active sessions exist and update is API-invoked", async () => { mockSessions.value = [{ id: "feat-1", status: "working" }]; - await expect( - program.parseAsync(["node", "test", "update"]), - ).rejects.toThrow("process.exit(1)"); - expect(mockSpawn).not.toHaveBeenCalled(); + mockExistsSync.mockReturnValue(false); + mockGetRunning + .mockResolvedValueOnce({ + pid: 12345, + configPath: "/tmp/test-global-config.yaml", + port: 3000, + startedAt: new Date().toISOString(), + projects: ["my-app"], + }) + .mockResolvedValue(null); + + await program.parseAsync(["node", "test", "update"]); + + expect(mockSpawn.mock.calls[0][0]).toBe("ao"); + expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]); + expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe("ao"); + expect(mockSpawn.mock.calls.at(-1)?.[1]).toEqual(["start", "my-app", "--restore"]); }); }); @@ -1128,17 +1084,15 @@ describe("update command", () => { it("passes shell:true and windowsHide:true on Windows so PATHEXT resolves npm.cmd", async () => { mockIsWindows.mockReturnValue(true); await program.parseAsync(["node", "test", "update"]); - expect(mockSpawn).toHaveBeenCalledTimes(1); const opts = mockSpawn.mock.calls[0][2] as Record; expect(opts.shell).toBe(true); expect(opts.windowsHide).toBe(true); - expect(opts.stdio).toBe("inherit"); + expect(opts.stdio).toEqual(["inherit", "pipe", "pipe"]); }); it("passes shell:false on macOS / Linux (no shell wrap needed)", async () => { mockIsWindows.mockReturnValue(false); await program.parseAsync(["node", "test", "update"]); - expect(mockSpawn).toHaveBeenCalledTimes(1); const opts = mockSpawn.mock.calls[0][2] as Record; expect(opts.shell).toBe(false); }); diff --git a/packages/cli/__tests__/lib/dashboard-url.test.ts b/packages/cli/__tests__/lib/dashboard-url.test.ts new file mode 100644 index 000000000..e97b83b3f --- /dev/null +++ b/packages/cli/__tests__/lib/dashboard-url.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { dashboardUrl } from "../../src/lib/dashboard-url.js"; + +describe("dashboardUrl", () => { + const original = process.env.AO_PUBLIC_URL; + + beforeEach(() => { + delete process.env.AO_PUBLIC_URL; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.AO_PUBLIC_URL; + } else { + process.env.AO_PUBLIC_URL = original; + } + }); + + it("falls back to localhost when AO_PUBLIC_URL is unset", () => { + expect(dashboardUrl(3000)).toBe("http://localhost:3000"); + }); + + it("falls back to localhost when AO_PUBLIC_URL is empty", () => { + process.env.AO_PUBLIC_URL = ""; + expect(dashboardUrl(8094)).toBe("http://localhost:8094"); + }); + + it("falls back to localhost when AO_PUBLIC_URL is whitespace only", () => { + process.env.AO_PUBLIC_URL = " "; + expect(dashboardUrl(8094)).toBe("http://localhost:8094"); + }); + + it("uses AO_PUBLIC_URL when set", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("ignores the port argument when AO_PUBLIC_URL is set", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + expect(dashboardUrl(8094)).toBe("https://ao.example.com"); + }); + + it("strips a trailing slash from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com/"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("strips multiple trailing slashes from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com///"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("preserves a sub-path in AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://example.com/ao"; + expect(dashboardUrl(3000)).toBe("https://example.com/ao"); + }); + + it("trims surrounding whitespace from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = " https://ao.example.com "; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("supports a non-default port in AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "http://192.168.1.5:9000"; + expect(dashboardUrl(3000)).toBe("http://192.168.1.5:9000"); + }); +}); 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__/lib/update-check.test.ts b/packages/cli/__tests__/lib/update-check.test.ts index 4b160d426..889712372 100644 --- a/packages/cli/__tests__/lib/update-check.test.ts +++ b/packages/cli/__tests__/lib/update-check.test.ts @@ -84,6 +84,7 @@ import { maybeShowUpdateNotice, scheduleBackgroundRefresh, isVersionOutdated, + isOutdatedForChannel, resolveUpdateChannel, resolveInstallMethodOverride, isManualOnlyInstall, @@ -115,6 +116,42 @@ describe("update-check", () => { mockGlobalConfig.value = null; }); + // ----------------------------------------------------------------------- + // isOutdatedForChannel + // ----------------------------------------------------------------------- + + describe("isOutdatedForChannel", () => { + it("treats any nightly dist-tag identity change as an update in both lexical directions", () => { + expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe(true); + expect(isOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe(true); + expect( + isOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"), + ).toBe(true); + }); + + it("does not update nightly when the exact dist-tag version is already installed", () => { + expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe(false); + }); + + it("uses semver for stable-to-nightly channel switches", () => { + expect(isOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false); + expect(isOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true); + expect(isOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false); + }); + + it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => { + // `fetchLatestVersion("nightly")` can fall back to `latest` if the + // nightly dist-tag is absent; prerelease-to-stable is a normal + // semver upgrade. + expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true); + }); + + it("keeps stable comparisons numeric instead of lexical", () => { + expect(isOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true); + expect(isOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true); + }); + }); + // ----------------------------------------------------------------------- // isVersionOutdated // ----------------------------------------------------------------------- @@ -1186,24 +1223,14 @@ describe("update-check", () => { describe("getUpdateCommand — channel-aware (Section B)", () => { it("uses @nightly tag for nightly channel", () => { - expect(getUpdateCommand("npm-global", "nightly")).toBe( - "npm install -g @aoagents/ao@nightly", - ); - expect(getUpdateCommand("pnpm-global", "nightly")).toBe( - "pnpm add -g @aoagents/ao@nightly", - ); - expect(getUpdateCommand("bun-global", "nightly")).toBe( - "bun add -g @aoagents/ao@nightly", - ); + expect(getUpdateCommand("npm-global", "nightly")).toBe("npm install -g @aoagents/ao@nightly"); + expect(getUpdateCommand("pnpm-global", "nightly")).toBe("pnpm add -g @aoagents/ao@nightly"); + expect(getUpdateCommand("bun-global", "nightly")).toBe("bun add -g @aoagents/ao@nightly"); }); it("uses @latest tag for stable + manual channels", () => { - expect(getUpdateCommand("npm-global", "stable")).toBe( - "npm install -g @aoagents/ao@latest", - ); - expect(getUpdateCommand("npm-global", "manual")).toBe( - "npm install -g @aoagents/ao@latest", - ); + expect(getUpdateCommand("npm-global", "stable")).toBe("npm install -g @aoagents/ao@latest"); + expect(getUpdateCommand("npm-global", "manual")).toBe("npm install -g @aoagents/ao@latest"); }); it("returns the brew upgrade notice for homebrew installs", () => { 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/doctor-script.test.ts b/packages/cli/__tests__/scripts/doctor-script.test.ts index 6315d48ef..514100cac 100644 --- a/packages/cli/__tests__/scripts/doctor-script.test.ts +++ b/packages/cli/__tests__/scripts/doctor-script.test.ts @@ -7,6 +7,7 @@ import { readFileSync, rmSync, symlinkSync, + statSync, utimesSync, writeFileSync, } from "node:fs"; @@ -34,7 +35,14 @@ function createHealthyRepo(tempRoot: string): string { mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true }); mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true }); - writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n"); + writeFileSync( + join(fakeRepo, "packages", "core", "package.json"), + JSON.stringify({ type: "module", main: "dist/index.js" }, null, 2), + ); + writeFileSync( + join(fakeRepo, "packages", "core", "dist", "index.js"), + 'export function getNodePtyPrebuildsSubdir() { return process.platform + "-" + process.arch; }\n', + ); writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n"); writeFileSync( join(fakeRepo, "packages", "ao", "bin", "ao.js"), @@ -61,7 +69,7 @@ function createHealthyPath(binDir: string): void { createFakeBinary( binDir, "node", - 'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0', + `if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"`, ); createFakeBinary( binDir, @@ -252,6 +260,67 @@ exit 0`, expect(npmCommands).toContain("link --force"); }); + it("warns about and repairs a non-executable node-pty spawn-helper", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-node-pty-helper-")); + const fakeRepo = createHealthyRepo(tempRoot); + const binDir = join(tempRoot, "bin"); + mkdirSync(binDir, { recursive: true }); + createHealthyPath(binDir); + + const helperPath = join( + fakeRepo, + "node_modules", + "node-pty", + "prebuilds", + `${process.platform}-${process.arch}`, + "spawn-helper", + ); + mkdirSync(dirname(helperPath), { recursive: true }); + writeFileSync(join(fakeRepo, "node_modules", "node-pty", "package.json"), "{}\n"); + writeFileSync(helperPath, "#!/bin/sh\nexit 0\n"); + chmodSync(helperPath, 0o644); + + const configPath = join(tempRoot, "agent-orchestrator.yaml"); + const dataDir = join(tempRoot, "data"); + const worktreeDir = join(tempRoot, "worktrees"); + mkdirSync(dataDir, { recursive: true }); + mkdirSync(worktreeDir, { recursive: true }); + writeFileSync( + configPath, + [`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"), + ); + + const warnResult = spawnSync("bash", [scriptPath], { + env: { + ...process.env, + PATH: `${binDir}:/bin:/usr/bin`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + }, + encoding: "utf8", + }); + + const fixResult = spawnSync("bash", [scriptPath, "--fix"], { + env: { + ...process.env, + PATH: `${binDir}:/bin:/usr/bin`, + AO_REPO_ROOT: fakeRepo, + AO_CONFIG_PATH: configPath, + }, + encoding: "utf8", + }); + + const statMode = (statSync(helperPath).mode & 0o111) !== 0; + rmSync(tempRoot, { recursive: true, force: true }); + + expect(warnResult.status).toBe(0); + expect(warnResult.stdout).toContain("WARN node-pty spawn-helper is not executable"); + expect(warnResult.stdout).toContain("posix_spawnp failed"); + expect(fixResult.status).toBe(0); + expect(fixResult.stdout).toContain("FIXED chmod +x applied to node-pty spawn-helper"); + expect(statMode).toBe(true); + }); + it("reports a healthy packaged install without source-checkout failures", () => { const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-package-")); const fakeInstall = createHealthyPackageInstall(tempRoot); diff --git a/packages/cli/package.json b/packages/cli/package.json index 99bd6290d..ea210f849 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-cli", - "version": "0.8.0", + "version": "0.9.0", "description": "CLI for agent-orchestrator — the `ao` command", "license": "MIT", "type": "module", @@ -34,13 +34,16 @@ }, "dependencies": { "@aoagents/ao-core": "workspace:*", + "@aoagents/ao-notifier-macos": "workspace:*", "@aoagents/ao-plugin-agent-aider": "workspace:*", "@aoagents/ao-plugin-agent-claude-code": "workspace:*", "@aoagents/ao-plugin-agent-codex": "workspace:*", "@aoagents/ao-plugin-agent-cursor": "workspace:*", + "@aoagents/ao-plugin-agent-grok": "workspace:*", "@aoagents/ao-plugin-agent-kimicode": "workspace:*", "@aoagents/ao-plugin-agent-opencode": "workspace:*", "@aoagents/ao-plugin-notifier-composio": "workspace:*", + "@aoagents/ao-plugin-notifier-dashboard": "workspace:*", "@aoagents/ao-plugin-notifier-desktop": "workspace:*", "@aoagents/ao-plugin-notifier-discord": "workspace:*", "@aoagents/ao-plugin-notifier-openclaw": "workspace:*", @@ -57,14 +60,16 @@ "@aoagents/ao-plugin-workspace-worktree": "workspace:*", "@aoagents/ao-web": "workspace:*", "@clack/prompts": "^0.9.1", + "@composio/core": "^0.9.0", "chalk": "^5.4.0", "commander": "^13.0.0", "ora": "^8.1.0", - "yaml": "^2.7.0" + "yaml": "^2.7.0", + "zod": "^3.25.76" }, "devDependencies": { - "@vitest/coverage-v8": "^3.0.0", "@types/node": "^25.2.3", + "@vitest/coverage-v8": "^3.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/cli/src/assets/scripts/ao-doctor.sh b/packages/cli/src/assets/scripts/ao-doctor.sh index bd3fd8895..2b20735bf 100755 --- a/packages/cli/src/assets/scripts/ao-doctor.sh +++ b/packages/cli/src/assets/scripts/ao-doctor.sh @@ -13,10 +13,10 @@ while [ $# -gt 0 ]; do cat <<'EOF' Usage: ao doctor [--fix] -Checks install, PATH, binaries, service health, stale temp files, and runtime sanity. +Checks install, PATH, binaries, service health, web terminal support, stale temp files, and runtime sanity. Options: - --fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files + --fix Apply safe fixes for missing launcher links, missing support dirs, node-pty spawn-helper permissions, and stale temp files EOF exit 0 ;; @@ -398,6 +398,136 @@ check_stale_temp_files() { warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix" } +file_mode_octal() { + case "$(uname -s 2>/dev/null || true)" in + Darwin|FreeBSD|OpenBSD|NetBSD) + stat -f '%Lp' "$1" 2>/dev/null || printf 'unknown' + ;; + *) + stat -c '%a' "$1" 2>/dev/null || printf 'unknown' + ;; + esac +} + +resolve_node_pty_spawn_helper() { + node - "$REPO_ROOT" <<'NODE' +const fs = require("node:fs"); +const { createRequire } = require("node:module"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const repoRoot = process.argv[2]; + +function resolvePackageJson(fromDir) { + try { + return createRequire(path.join(fromDir, "ao-doctor.js")).resolve("node-pty/package.json"); + } catch { + return null; + } +} + +function findPackageUp(startDir, ...segments) { + let dir = path.resolve(startDir); + while (true) { + const candidate = path.resolve(dir, "node_modules", ...segments); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function resolveNodeModulesPackage(fromDir, ...segments) { + const packageDir = path.resolve(fromDir, "node_modules", ...segments); + return fs.existsSync(path.resolve(packageDir, "package.json")) ? packageDir : null; +} + +function resolveCoreEntrypoint() { + const sourceCoreDir = path.resolve(repoRoot, "packages", "core"); + const coreDir = + (fs.existsSync(path.join(sourceCoreDir, "package.json")) ? sourceCoreDir : null) ?? + findPackageUp(repoRoot, "@aoagents", "ao-core") ?? + resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-core"); + if (!coreDir) return null; + + try { + const pkg = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8")); + const entry = pkg.exports?.["."]?.import ?? pkg.module ?? pkg.main ?? "dist/index.js"; + return path.resolve(coreDir, entry); + } catch { + return null; + } +} + +async function getNodePtyPrebuildsSubdir() { + const coreEntrypoint = resolveCoreEntrypoint(); + if (!coreEntrypoint || !fs.existsSync(coreEntrypoint)) return null; + + const core = await import(pathToFileURL(coreEntrypoint).href); + return typeof core.getNodePtyPrebuildsSubdir === "function" + ? core.getNodePtyPrebuildsSubdir() + : null; +} + +(async () => { + const packageJsonPath = + resolvePackageJson(repoRoot) ?? + (() => { + const directNodePtyDir = findPackageUp(repoRoot, "node-pty"); + if (directNodePtyDir) return path.join(directNodePtyDir, "package.json"); + + const sourceWebDir = path.resolve(repoRoot, "packages", "web"); + const webDir = + (fs.existsSync(path.join(sourceWebDir, "package.json")) ? sourceWebDir : null) ?? + findPackageUp(repoRoot, "@aoagents", "ao-web") ?? + resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-web"); + if (!webDir) return null; + + const webNodePtyDir = + resolveNodeModulesPackage(webDir, "node-pty") ?? findPackageUp(webDir, "node-pty"); + return webNodePtyDir ? path.join(webNodePtyDir, "package.json") : null; + })(); + + const prebuildsSubdir = await getNodePtyPrebuildsSubdir(); + if (!packageJsonPath || !prebuildsSubdir) process.exit(0); + + console.log(path.join(path.dirname(packageJsonPath), "prebuilds", prebuildsSubdir, "spawn-helper")); +})().catch(() => process.exit(0)); +NODE +} + +check_node_pty_spawn_helper() { + if ! command -v node >/dev/null 2>&1; then + warn "node-pty spawn-helper check skipped because node is unavailable" + return + fi + + local helper_path mode + helper_path="$(resolve_node_pty_spawn_helper 2>/dev/null || true)" + if [ -z "$helper_path" ] || [ ! -f "$helper_path" ]; then + pass "node-pty spawn-helper check skipped because no helper was found for this platform" + return + fi + + mode="$(file_mode_octal "$helper_path")" + if [ -x "$helper_path" ]; then + pass "node-pty spawn-helper is executable at $helper_path (mode 0o$mode)" + return + fi + + if [ "$FIX_MODE" = true ]; then + if chmod 755 "$helper_path"; then + fixed "chmod +x applied to node-pty spawn-helper at $helper_path (was 0o$mode)" + return + fi + warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode), and chmod failed. Web dashboard terminals can fail with posix_spawnp failed. Fix: chmod +x $helper_path" + return + fi + + warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode). Web dashboard terminals can fail with posix_spawnp failed. Fix: run ao doctor --fix or chmod +x $helper_path. See ao#1770." +} + printf 'Agent Orchestrator Doctor\n\n' check_node @@ -409,6 +539,7 @@ check_gh check_config_dirs check_stale_temp_files check_install_layout +check_node_pty_spawn_helper check_runtime_sanity printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT" diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index addd9bd30..f9abbf817 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -12,6 +12,7 @@ import { } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; import { DEFAULT_PORT } from "../lib/constants.js"; +import { dashboardUrl } from "../lib/dashboard-url.js"; export function registerDashboard(program: Command): void { program @@ -42,7 +43,7 @@ export function registerDashboard(program: Command): void { const webDir = localWebDir; - console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); + console.log(chalk.bold(`Starting dashboard on ${dashboardUrl(port)}\n`)); const env = await buildDashboardEnv( port, @@ -90,7 +91,7 @@ export function registerDashboard(program: Command): void { if (opts.open !== false) { openAbort = new AbortController(); - void waitForPortAndOpen(port, `http://localhost:${port}`, openAbort.signal); + void waitForPortAndOpen(port, dashboardUrl(port), openAbort.signal); } child.on("exit", (code) => { 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/send.ts b/packages/cli/src/commands/send.ts index fa79cb9a1..57d84e97b 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -32,7 +32,7 @@ async function resolveSessionContext(sessionName: string): Promise<{ if (session) { const tmuxTarget = session.runtimeHandle?.id ?? sessionName; const project = config.projects[session.projectId]; - const agentName = session.metadata["agent"] ?? project?.agent ?? config.defaults.agent; + const agentName = session.metadata["agent"]!; const runtimeName = session.runtimeHandle?.runtimeName ?? project?.runtime ?? config.defaults.runtime; return { 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 1b5841a30..79850587c 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -32,6 +32,7 @@ import { findPidByPort, killProcessTree, loadLocalProjectConfigDetailed, + recordActivityEvent, registerProjectInGlobalConfig, getGlobalConfigPath, type OrchestratorConfig, @@ -86,6 +87,7 @@ import { type DetectedAgent, } from "../lib/detect-agent.js"; import { detectDefaultBranch } from "../lib/git-utils.js"; +import { dashboardUrl } from "../lib/dashboard-url.js"; import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js"; import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js"; import { @@ -116,6 +118,17 @@ import { projectSessionUrl } from "../lib/routes.js"; // HELPERS // ============================================================================= +class CliFailureEventRecordedError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CliFailureEventRecordedError"; + } +} + +function isCliFailureEventRecordedError(err: unknown): boolean { + return err instanceof CliFailureEventRecordedError; +} + function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig { const localConfig = loadLocalProjectConfigDetailed(projectPath); if (localConfig.kind === "loaded") { @@ -162,6 +175,15 @@ async function registerFlatConfig(configPath: string): Promise { ...(repo ? { repo } : {}), }); + recordActivityEvent({ + projectId: registeredProjectId, + source: "cli", + kind: "cli.config_migrated", + level: "info", + summary: `flat config registered into global config`, + data: { projectPath, configPath }, + }); + console.log(chalk.green(` ✓ Registered "${registeredProjectId}"\n`)); return registeredProjectId; } @@ -856,7 +878,14 @@ async function runStartup( config: OrchestratorConfig, projectId: string, project: ProjectConfig, - opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; dev?: boolean }, + opts?: { + dashboard?: boolean; + orchestrator?: boolean; + rebuild?: boolean; + dev?: boolean; + /** true = restore without prompting, false = skip restore, undefined = prompt for humans */ + restore?: boolean; + }, ): Promise { await runtimePreflight(config); @@ -915,7 +944,7 @@ async function runStartup( config.directTerminalPort, opts?.dev, ); - spinner.succeed(`Dashboard starting on http://localhost:${port}`); + spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } @@ -940,10 +969,21 @@ async function runStartup( } } catch (err) { spinner.fail("Orchestrator setup failed"); + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.start_failed", + level: "error", + summary: `orchestrator setup failed`, + data: { + reason: "orchestrator_setup", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (dashboardProcess) { dashboardProcess.kill(); } - throw new Error( + throw new CliFailureEventRecordedError( `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); @@ -953,33 +993,56 @@ async function runStartup( if (shouldStartLifecycle) { try { spinner.start("Starting project supervisor"); - await startProjectSupervisor(); + await startProjectSupervisor({ configPath: config.configPath }); spinner.succeed("Lifecycle project supervisor started"); } catch (err) { spinner.fail("Project supervisor failed to start"); + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.start_failed", + level: "error", + summary: `project supervisor failed to start`, + data: { + reason: "supervisor_start", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (dashboardProcess) { dashboardProcess.kill(); } - throw new Error( + throw new CliFailureEventRecordedError( `Failed to start project supervisor: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); } } - // Check for sessions from last `ao stop` and offer to restore them - if (isHumanCaller()) { + // Check for sessions from last `ao stop` and restore/prompt/skip based on caller intent. + if (opts?.restore !== false && (opts?.restore === true || isHumanCaller())) { try { const lastStop = await readLastStop(); - if (lastStop && lastStop.sessionIds.length > 0) { + const totalLastStopSessions = + (lastStop?.sessionIds.length ?? 0) + + (lastStop?.otherProjects ?? []).reduce((sum, p) => sum + p.sessionIds.length, 0); + if (lastStop && totalLastStopSessions > 0) { const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`; const otherProjects = lastStop.otherProjects ?? []; + const restoreProjectBySessionId = new Map(); // Build flat list of all sessions to restore, grouped for display const allRestoreSessions: string[] = [ ...(lastStop.projectId === projectId ? lastStop.sessionIds : []), ...otherProjects.flatMap((p) => p.sessionIds), ]; + for (const sessionId of lastStop.sessionIds) { + restoreProjectBySessionId.set(sessionId, lastStop.projectId); + } + for (const otherProject of otherProjects) { + for (const sessionId of otherProject.sessionIds) { + restoreProjectBySessionId.set(sessionId, otherProject.projectId); + } + } // Display grouped by project const currentProjectSessions = lastStop.projectId === projectId ? lastStop.sessionIds : []; @@ -1003,8 +1066,20 @@ async function runStartup( } if (allRestoreSessions.length > 0) { - const shouldRestore = await promptConfirm("Restore these sessions?", true); + const shouldRestore = + opts?.restore === true ? true : await promptConfirm("Restore these sessions?", true); if (shouldRestore) { + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.restore_started", + level: "info", + summary: `restoring ${allRestoreSessions.length} session(s) from last-stop`, + data: { + sessionCount: allRestoreSessions.length, + stoppedAt: lastStop.stoppedAt, + }, + }); // Use global config so the session manager can see all projects let restoreConfig = config; if (otherProjects.length > 0) { @@ -1029,11 +1104,33 @@ async function runStartup( restoredCount++; } catch (err) { failedSessionIds.add(sessionId); + const restoreProjectId = restoreProjectBySessionId.get(sessionId) ?? projectId; + recordActivityEvent({ + projectId: restoreProjectId, + sessionId, + source: "cli", + kind: "cli.restore_session_failed", + level: "warn", + summary: `failed to restore session`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); warnings.push( ` Warning: could not restore ${sessionId}: ${err instanceof Error ? err.message : String(err)}`, ); } } + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.restore_completed", + level: "info", + summary: `restored ${restoredCount}/${allRestoreSessions.length} session(s)`, + data: { + requested: allRestoreSessions.length, + restored: restoredCount, + failed: failedSessionIds.size, + }, + }); if (restoredCount === allRestoreSessions.length) { restoreSpinner.succeed( `Restored ${restoredCount}/${allRestoreSessions.length} session(s)`, @@ -1086,7 +1183,15 @@ async function runStartup( await clearLastStop(); } } - } catch { + } catch (err) { + recordActivityEvent({ + projectId, + source: "cli", + kind: "cli.last_stop_read_failed", + level: "warn", + summary: `failed to read or process last-stop state during startup`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); // Non-fatal: don't block startup if last-stop handling fails } } @@ -1095,7 +1200,7 @@ async function runStartup( console.log(chalk.bold.green("\n✓ Startup complete\n")); if (opts?.dashboard !== false) { - console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`); + console.log(chalk.cyan("Dashboard:"), dashboardUrl(port)); } if (shouldStartLifecycle) { @@ -1129,7 +1234,7 @@ async function runStartup( openAbort = new AbortController(); const orchestratorUrl = selectedOrchestratorId ? projectSessionUrl(port, projectId, selectedOrchestratorId) - : `http://localhost:${port}`; + : dashboardUrl(port); void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal); } @@ -1324,10 +1429,10 @@ async function attachAndSpawnOrchestrator(opts: { } if (isHumanCaller()) { - console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`)); - openUrl(`http://localhost:${daemon.port}`); + console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`)); + openUrl(dashboardUrl(daemon.port)); } else { - console.log(`Dashboard: http://localhost:${daemon.port}`); + console.log(`Dashboard: ${dashboardUrl(daemon.port)}`); } } @@ -1347,6 +1452,8 @@ export function registerStart(program: Command): void { .option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)") .option("--interactive", "Prompt to configure config settings") .option("--reap-orphans", "Kill orphaned AO child processes before starting") + .option("--restore", "Restore sessions from last ao stop without prompting") + .option("--no-restore", "Skip restoring sessions from last ao stop") .action( async ( projectArg?: string, @@ -1357,8 +1464,24 @@ export function registerStart(program: Command): void { dev?: boolean; interactive?: boolean; reapOrphans?: boolean; + restore?: boolean; }, ) => { + recordActivityEvent({ + source: "cli", + kind: "cli.start_invoked", + level: "info", + summary: "ao start invoked", + data: { + projectArg: projectArg ?? null, + dashboard: opts?.dashboard !== false, + orchestrator: opts?.orchestrator !== false, + rebuild: opts?.rebuild === true, + dev: opts?.dev === true, + interactive: opts?.interactive === true, + }, + }); + let releaseStartupLock: (() => void) | undefined; let startupLockReleased = false; const unlockStartup = (): void => { @@ -1394,7 +1517,7 @@ export function registerStart(program: Command): void { // exit. Project-id args fall through to attach+spawn so // automation can `ao start ` against a live daemon. console.log(`AO is already running.`); - console.log(`Dashboard: http://localhost:${running.port}`); + console.log(`Dashboard: ${dashboardUrl(running.port)}`); console.log(`PID: ${running.pid}`); console.log(`Projects: ${running.projects.join(", ")}`); console.log(`To restart: ao stop && ao start`); @@ -1404,7 +1527,7 @@ export function registerStart(program: Command): void { if (isHumanCaller() && !projectArg) { console.log(chalk.cyan(`\nℹ AO is already running.`)); - console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`); console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`); console.log(` Projects: ${running.projects.join(", ")}\n`); @@ -1451,7 +1574,7 @@ export function registerStart(program: Command): void { ); if (choice === "open") { - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } else if (choice === "quit") { @@ -1483,7 +1606,7 @@ export function registerStart(program: Command): void { ), ); } - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } else if (choice === "new") { @@ -1491,6 +1614,13 @@ export function registerStart(program: Command): void { // Resolve happens below; the suffix mutation runs after. startNewOrchestrator = true; } else if (choice === "restart") { + recordActivityEvent({ + source: "cli", + kind: "cli.daemon_restart", + level: "info", + summary: `user chose restart, killing existing daemon`, + data: { existingPid: running.pid, existingPort: running.port }, + }); await killExistingDaemon(running); console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n")); running = null; @@ -1562,9 +1692,9 @@ export function registerStart(program: Command): void { running.projects.includes(projectId) ) { console.log(chalk.cyan(`\nℹ AO is already running.`)); - console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`); console.log(` Project "${projectId}" is already registered and running.\n`); - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } @@ -1649,6 +1779,18 @@ export function registerStart(program: Command): void { // Ctrl+C and `ao stop` (which sends SIGTERM) perform a full // graceful shutdown via the handler installed inside runStartup(). } catch (err) { + if (!isCliFailureEventRecordedError(err)) { + recordActivityEvent({ + source: "cli", + kind: "cli.start_failed", + level: "error", + summary: `ao start action failed`, + data: { + reason: "outer", + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + } if (err instanceof Error) { console.error(chalk.red("\nError:"), err.message); } else { @@ -1719,7 +1861,19 @@ export function registerStop(program: Command): void { .description("Stop orchestrator agent and dashboard") .option("--purge-session", "Delete mapped OpenCode session when stopping") .option("--all", "Stop all running AO instances") - .action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => { + .option("-y, --yes", "Confirm stopping active sessions without prompting") + .action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {}) => { + recordActivityEvent({ + source: "cli", + kind: "cli.stop_invoked", + level: "info", + summary: "ao stop invoked", + data: { + projectArg: projectArg ?? null, + all: opts.all === true, + purgeSession: opts.purgeSession === true, + }, + }); try { // Check running.json first const running = await getRunning(); @@ -1757,10 +1911,30 @@ export function registerStop(program: Command): void { config = loadConfig(globalPath); } } - const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"); + let _projectId: string; + let project: ProjectConfig; + if (projectArg) { + ({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop")); + } else { + const projectIds = Object.keys(config.projects); + if (projectIds.length === 0) { + throw new Error("No projects configured. Add a project to agent-orchestrator.yaml."); + } + const currentDir = resolve(cwd()); + const cwdProjectId = findProjectForDirectory(config.projects, currentDir); + _projectId = + running?.projects.find((id) => config.projects[id]) ?? + cwdProjectId ?? + projectIds[0]; + project = config.projects[_projectId]; + } const port = config.port ?? DEFAULT_PORT; - console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); + if (projectArg) { + console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); + } else { + console.log(chalk.bold(`\nStopping AO across all projects\n`)); + } const sm = await getSessionManager(config); try { @@ -1786,6 +1960,16 @@ export function registerStop(program: Command): void { const otherByProject = new Map(); if (activeSessions.length > 0) { + if (!projectArg && opts.yes !== true && isHumanCaller()) { + const confirmed = await promptConfirm( + `Stop AO and ${activeSessions.length} active session(s)?`, + false, + ); + if (!confirmed) { + console.log(chalk.yellow("Stop cancelled.")); + return; + } + } const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start(); const purgeOpenCode = opts?.purgeSession === true; const warnings: string[] = []; @@ -1796,6 +1980,15 @@ export function registerStop(program: Command): void { killedSessionIds.push(session.id); } } catch (err) { + recordActivityEvent({ + projectId: session.projectId ?? _projectId, + sessionId: session.id, + source: "cli", + kind: "cli.stop_session_failed", + level: "warn", + summary: `failed to kill session during ao stop`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); warnings.push( ` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`, ); @@ -1840,12 +2033,48 @@ export function registerStop(program: Command): void { otherProjects.push({ projectId: pid, sessionIds: ids }); } - await writeLastStop({ - stoppedAt: new Date().toISOString(), - projectId: _projectId, - sessionIds: killedSessionIds.filter((id) => targetActive.some((s) => s.id === id)), - otherProjects: otherProjects.length > 0 ? otherProjects : undefined, - }); + const targetSessionIds = killedSessionIds.filter((id) => + targetActive.some((s) => s.id === id), + ); + try { + await writeLastStop({ + stoppedAt: new Date().toISOString(), + projectId: _projectId, + sessionIds: targetSessionIds, + otherProjects: otherProjects.length > 0 ? otherProjects : undefined, + }); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_written", + level: "info", + summary: `last-stop state written with ${killedSessionIds.length} session(s)`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + }, + }); + } catch (err) { + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_write_failed", + level: "error", + summary: `failed to write last-stop state during ao stop`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + console.log( + chalk.yellow( + ` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + } } } catch (err) { console.log( @@ -1872,7 +2101,29 @@ export function registerStop(program: Command): void { // 0x800700e8). No-op on non-Windows. await sweepWindowsPtyHostsBeforeParentKill(); await sweepRegisteredDaemonChildren(running.pid); - await killProcessTree(running.pid, "SIGTERM"); + try { + await killProcessTree(running.pid, "SIGTERM"); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "info", + summary: `SIGTERM sent to parent ao start`, + data: { pid: running.pid, port: running.port }, + }); + } catch (err) { + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "warn", + summary: `parent ao start was already dead`, + data: { + pid: running.pid, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + } await unregister(); } else { await sweepRegisteredDaemonChildren(); @@ -1892,6 +2143,16 @@ export function registerStop(program: Command): void { console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`)); } } catch (err) { + recordActivityEvent({ + source: "cli", + kind: "cli.stop_failed", + level: "error", + summary: `ao stop action failed`, + data: { + projectArg: projectArg ?? null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); if (err instanceof Error) { console.error(chalk.red("\nError:"), err.message); } else { diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 16e063bcc..df1804213 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -3,7 +3,6 @@ import type { Command } from "commander"; import { createInitialCanonicalLifecycle, createActivitySignal, - type Agent, type SCM, type Session, type PRInfo, @@ -13,12 +12,16 @@ import { type Tracker, type ProjectConfig, type AgentReportAuditEntry, + type PluginRegistry, isOrchestratorSession, isTerminalSession, isWindows, loadConfig, getProjectSessionsDir, readAgentReportAuditTrailAsync, + createCodeReviewStore, + type CodeReviewRunStatus, + type CodeReviewRunSummary, } from "@aoagents/ao-core"; import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js"; import { @@ -61,6 +64,24 @@ interface StatusOptions { reports?: string; } +interface ProjectReviewStatus { + projectId: string; + runs: CodeReviewRunSummary[]; + runCount: number; + activeRunCount: number; + openFindingCount: number; +} + +const REVIEW_ATTENTION_STATUSES: ReadonlySet = new Set([ + "queued", + "preparing", + "running", + "needs_triage", + "sent_to_agent", + "waiting_update", + "failed", +]); + /** Parse --reports value: "full" → Infinity, positive integer → N, undefined → 0 (off). */ function parseReportsLimit(value: string | undefined): number { if (value === undefined) return 0; @@ -89,9 +110,24 @@ function maybeClearScreen(): void { } } +function gatherProjectReviewStatus(projectId: string): ProjectReviewStatus { + const runs = createCodeReviewStore(projectId).listRunSummaries(); + const activeRunCount = runs.filter( + (run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0, + ).length; + const openFindingCount = runs.reduce((sum, run) => sum + run.openFindingCount, 0); + return { + projectId, + runs, + runCount: runs.length, + activeRunCount, + openFindingCount, + }; +} + async function gatherSessionInfo( session: Session, - agent: Agent, + registry: PluginRegistry, scm: SCM, projectConfig: ReturnType, reportsLimit: number = 0, @@ -124,11 +160,17 @@ async function gatherSessionInfo( lastActivity = activityTs ? formatAge(activityTs) : "-"; } - // Get agent's auto-generated summary via introspection + // Get agent's auto-generated summary via introspection. The SessionManager + // normalizes session.metadata.agent on read, so never infer the session agent + // from current project/default config here. let claudeSummary: string | null = null; try { - const introspection = await agent.getSessionInfo(session); - claudeSummary = introspection?.summary ?? null; + const agentName = session.metadata["agent"]; + if (agentName) { + const agent = getAgentByNameFromRegistry(registry, agentName); + const introspection = await agent.getSessionInfo(session); + claudeSummary = introspection?.summary ?? null; + } } catch { // Summary extraction failed — not critical } @@ -306,6 +348,44 @@ function printOrchestratorRow(info: SessionInfo): void { printReportRows(info.reports, " "); } +function printReviewStatus(summary: ProjectReviewStatus): void { + if (summary.runCount === 0) return; + + const findings = + summary.openFindingCount === 1 + ? "1 open finding" + : `${summary.openFindingCount} open findings`; + const active = + summary.activeRunCount === 1 ? "1 active" : `${summary.activeRunCount} active`; + + console.log( + ` ${chalk.magenta("Reviews:")} ${summary.runCount} run${summary.runCount !== 1 ? "s" : ""} · ${active} · ${findings}`, + ); + + const visibleRuns = summary.runs + .filter((run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0) + .slice(0, 5); + + for (const run of visibleRuns) { + const findingText = + run.openFindingCount === 1 + ? "1 open finding" + : `${run.openFindingCount} open findings`; + console.log( + ` ${chalk.green(run.reviewerSessionId)} ${chalk.dim(run.status)} → ${chalk.cyan(run.linkedSessionId)} ${chalk.dim(findingText)}`, + ); + } + + const hiddenCount = summary.runs.length - visibleRuns.length; + if (hiddenCount > 0) { + console.log( + chalk.dim( + ` ${hiddenCount} more review run${hiddenCount !== 1 ? "s" : ""}. Use \`ao review list ${summary.projectId}\`.`, + ), + ); + } +} + export function registerStatus(program: Command): void { program .command("status") @@ -406,8 +486,12 @@ export function registerStatus(program: Command): void { // Show projects that have no sessions too (if not filtered) const projectIds = opts.project ? [opts.project] : Object.keys(config.projects); const jsonOutput: SessionInfo[] = []; + const reviewOutput: CodeReviewRunSummary[] = []; let totalWorkers = 0; let totalOrchestrators = 0; + let totalReviewRuns = 0; + let totalActiveReviewRuns = 0; + let totalOpenReviewFindings = 0; for (const projectId of projectIds) { const projectConfig = config.projects[projectId]; @@ -416,10 +500,15 @@ export function registerStatus(program: Command): void { const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) => a.id.localeCompare(b.id), ); + const reviewStatus = gatherProjectReviewStatus(projectId); + reviewOutput.push(...reviewStatus.runs); + totalReviewRuns += reviewStatus.runCount; + totalActiveReviewRuns += reviewStatus.activeRunCount; + totalOpenReviewFindings += reviewStatus.openFindingCount; - // Resolve agent and SCM for this project via the shared registry - const agentName = projectConfig.agent ?? config.defaults.agent; - const agent = getAgentByNameFromRegistry(registry, agentName); + // Resolve SCM for this project via the shared registry. Agents are + // resolved per session because historical metadata may record a + // different agent than the current project default. const scm = getSCMFromRegistry(registry, config, projectId); if (!opts.json) { @@ -429,13 +518,16 @@ export function registerStatus(program: Command): void { if (projectSessions.length === 0) { if (!opts.json) { console.log(chalk.dim(" (no active sessions)")); + printReviewStatus(reviewStatus); console.log(); } continue; } // Gather all session info in parallel - const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config, reportsLimit)); + const infoPromises = projectSessions.map((s) => + gatherSessionInfo(s, registry, scm, config, reportsLimit), + ); const sessionInfos = await Promise.all(infoPromises); const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator"); @@ -462,6 +554,7 @@ export function registerStatus(program: Command): void { if (workers.length === 0) { console.log(chalk.dim(" (no active sessions)")); + printReviewStatus(reviewStatus); console.log(); continue; } @@ -470,13 +563,23 @@ export function registerStatus(program: Command): void { for (const info of workers) { printSessionRow(info); } + printReviewStatus(reviewStatus); console.log(); } if (opts.json) { console.log( JSON.stringify( - { data: jsonOutput, meta: { hiddenTerminatedCount } }, + { + data: jsonOutput, + reviews: reviewOutput, + meta: { + hiddenTerminatedCount, + reviewRunCount: totalReviewRuns, + activeReviewRunCount: totalActiveReviewRuns, + openReviewFindingCount: totalOpenReviewFindings, + }, + }, null, 2, ), @@ -487,6 +590,12 @@ export function registerStatus(program: Command): void { ` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` + (totalOrchestrators > 0 ? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}` + : "") + + (totalReviewRuns > 0 + ? ` · ${totalReviewRuns} review run${totalReviewRuns !== 1 ? "s" : ""}` + + (totalOpenReviewFindings > 0 + ? ` · ${totalOpenReviewFindings} open finding${totalOpenReviewFindings !== 1 ? "s" : ""}` + : "") : ""), ), ); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 8364d613d..b181d4370 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"; @@ -67,12 +68,16 @@ export function registerUpdate(program: Command): void { .option("--skip-smoke", "Skip smoke tests after rebuilding (git installs only)") .option("--smoke-only", "Run smoke tests without fetching or rebuilding (git installs only)") .option("--check", "Print version info as JSON without upgrading") + .option("--no-restore", "Restart AO after updating but do not restore stopped sessions") .action( - async (opts: { skipSmoke?: boolean; smokeOnly?: boolean; check?: boolean }) => { + async (opts: { + skipSmoke?: boolean; + smokeOnly?: boolean; + check?: boolean; + restore?: boolean; + }) => { if (opts.skipSmoke && opts.smokeOnly) { - console.error( - "`ao update` does not allow `--skip-smoke` together with `--smoke-only`.", - ); + console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`."); process.exit(1); } @@ -83,6 +88,14 @@ export function registerUpdate(program: Command): void { const method = detectInstallMethod(); + recordActivityEvent({ + source: "cli", + kind: "cli.update_invoked", + level: "info", + summary: `ao update invoked (method: ${method})`, + data: { method, options: opts }, + }); + // Reject git-only flags up front when the install isn't a git source. // Without this, users copy/pasting `ao update --skip-smoke` from older // docs would silently no-op on npm/pnpm/bun installs (the flag would be @@ -104,7 +117,7 @@ export function registerUpdate(program: Command): void { case "npm-global": case "pnpm-global": case "bun-global": - await handleNpmUpdate(method); + await handleNpmUpdate(method, { restore: opts.restore !== false }); break; case "unknown": await handleUnknownUpdate(); @@ -124,21 +137,26 @@ async function handleCheck(): Promise { } // --------------------------------------------------------------------------- -// Active-session guard +// Update lifecycle planning // --------------------------------------------------------------------------- /** - * Refuse to update when the user has live sessions. - * - * Auto-stopping would lose the agent's in-flight context (and potentially - * uncommitted work). The release doc is explicit: refuse, surface the - * `ao stop` command, let the user decide. - * - * Best-effort — when no config is reachable (fresh install, broken yaml) - * we skip the guard rather than blocking the update on a missing dependency. + * Best-effort snapshot used by `ao update` to pause and resume AO around the + * package-manager install. Missing/broken config should not block an update; + * in that case we proceed without attempting a stop/start round trip. */ -async function ensureNoActiveSessions(): Promise { +interface UpdateLifecyclePlan { + runningBeforeUpdate: boolean; + configPath?: string; + primaryProjectId?: string; + activeSessions: Session[]; +} + +async function getUpdateLifecyclePlan(): Promise { let sessions: Session[]; + let configPath: string | undefined; + let primaryProjectId: string | undefined; + let runningBeforeUpdate = false; try { // Live signal first: running.json lists whichever projects the active // `ao start` daemon is currently polling. That can include local-only @@ -151,6 +169,9 @@ async function ensureNoActiveSessions(): Promise { // truth for "which sessions does the running ao instance own?" const running = await getRunning(); if (running && running.projects.length > 0) { + runningBeforeUpdate = true; + configPath = running.configPath; + primaryProjectId = running.projects[0]; // running.configPath could be local-wrapped (a project's // agent-orchestrator.yaml) OR the canonical global path. loadConfig // dispatches based on the path shape — both cases produce a full @@ -165,15 +186,19 @@ async function ensureNoActiveSessions(): Promise { // SessionManager's enrichment will reconcile any stale-runtime // sessions to `killed`, so terminal statuses don't block the update. const globalPath = getGlobalConfigPath(); - if (!existsSync(globalPath)) return true; + if (!existsSync(globalPath)) { + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; + } const globalConfig = loadGlobalConfig(globalPath); if (!globalConfig || Object.keys(globalConfig.projects).length === 0) { - return true; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } if (!isCanonicalGlobalConfigPath(globalPath)) { - return true; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } + configPath = globalPath; const config = loadConfig(globalPath); + primaryProjectId = Object.keys(config.projects)[0]; const sm = await getSessionManager(config); sessions = await sm.list(); } @@ -181,29 +206,132 @@ async function ensureNoActiveSessions(): Promise { // If we can't enumerate sessions, don't pretend there are zero — but // also don't block the upgrade indefinitely. Surface a soft warning. console.error( - chalk.yellow( - "⚠ Could not check for active sessions before updating. Proceeding anyway.", - ), + chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."), ); - return true; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] }; } const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status)); - if (active.length === 0) return true; + return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active }; +} - const noun = active.length === 1 ? "session" : "sessions"; - console.error( - chalk.red( - `\n✗ ${active.length} ${noun} active. Run \`ao stop\` first, then \`ao update\`.\n`, - ), - ); - for (const s of active.slice(0, 5)) { - console.error(chalk.dim(` • ${s.id} (${s.status})`)); +async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise { + const shouldStop = plan.runningBeforeUpdate || plan.activeSessions.length > 0; + if (!shouldStop) return false; + + if (plan.activeSessions.length > 0) { + const noun = plan.activeSessions.length === 1 ? "session" : "sessions"; + console.log( + chalk.yellow( + `\n${plan.activeSessions.length} active ${noun} will be paused and restored after the update.`, + ), + ); + for (const s of plan.activeSessions.slice(0, 5)) { + console.log(chalk.dim(` • ${s.id} (${s.status})`)); + } + if (plan.activeSessions.length > 5) { + console.log(chalk.dim(` … and ${plan.activeSessions.length - 5} more`)); + } + } else { + console.log(chalk.dim("\nAO is running; it will be restarted after the update.")); } - if (active.length > 5) { - console.error(chalk.dim(` … and ${active.length - 5} more`)); + + const stopExit = await runAoLifecycleCommand(["stop", "--yes"], { + configPath: plan.configPath, + }); + if (stopExit !== 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update failed: internal ao stop exited non-zero`, + data: { exitCode: stopExit }, + }); + console.error(chalk.red(`\nAO update could not stop the running daemon (exit ${stopExit}).`)); + process.exit(stopExit); } - return false; + + const afterStop = await getUpdateLifecyclePlan(); + if (afterStop.runningBeforeUpdate || afterStop.activeSessions.length > 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update failed: AO still appears active after internal ao stop`, + data: { + runningAfterStop: afterStop.runningBeforeUpdate, + activeSessionCount: afterStop.activeSessions.length, + activeSessionIds: afterStop.activeSessions.map((s) => s.id).slice(0, 20), + }, + }); + console.error( + chalk.red( + "\nAO update stopped before installing because AO still appears to be running after `ao stop --yes`.", + ), + ); + if (afterStop.activeSessions.length > 0) { + console.error(chalk.dim("Still-active sessions:")); + for (const s of afterStop.activeSessions.slice(0, 5)) { + console.error(chalk.dim(` • ${s.id} (${s.status})`)); + } + if (afterStop.activeSessions.length > 5) { + console.error(chalk.dim(` … and ${afterStop.activeSessions.length - 5} more`)); + } + } + console.error(chalk.dim("Run `ao stop` and retry `ao update` after AO is fully stopped.")); + process.exit(1); + } + + return plan.runningBeforeUpdate; +} + +async function restartAoAfterUpdate( + plan: UpdateLifecyclePlan, + opts: { restore: boolean }, +): Promise { + const args = ["start"]; + if (plan.primaryProjectId) args.push(plan.primaryProjectId); + args.push(opts.restore ? "--restore" : "--no-restore"); + + console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`)); + const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath }); + if (exitCode !== 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_restart_failed", + level: "error", + summary: `ao update could not restart AO after install`, + data: { exitCode, args }, + }); + console.error( + chalk.yellow( + `\nAO was updated, but \`ao ${args.join(" ")}\` failed with exit ${exitCode}. ` + + `Run it manually to restore your sessions.`, + ), + ); + process.exit(exitCode); + } +} + +function runAoLifecycleCommand( + args: string[], + opts: { configPath?: string } = {}, +): Promise { + return new Promise((resolveExit) => { + const child = spawn("ao", args, { + stdio: "inherit", + shell: isWindows(), + windowsHide: true, + env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env, + }); + child.on("error", (error) => { + console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`)); + resolveExit(1); + }); + child.on("exit", (code, signal) => { + resolveExit(signal ? 1 : (code ?? 1)); + }); + }); } // --------------------------------------------------------------------------- @@ -213,10 +341,10 @@ async function ensureNoActiveSessions(): Promise { async function handleGitUpdate(opts: { skipSmoke?: boolean; smokeOnly?: boolean; + restore?: boolean; }): Promise { - if (!(await ensureNoActiveSessions())) { - process.exit(1); - } + const lifecyclePlan = await getUpdateLifecyclePlan(); + const shouldRestart = await pauseAoForUpdate(lifecyclePlan); const args: string[] = []; if (opts.skipSmoke) args.push("--skip-smoke"); @@ -225,14 +353,31 @@ async function handleGitUpdate(opts: { try { const exitCode = await runRepoScript("ao-update.sh", args); if (exitCode !== 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (git) failed: ao-update.sh exited non-zero`, + data: { method: "git", exitCode }, + }); + if (shouldRestart) { + await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false }); + } process.exit(exitCode); } invalidateCache(); + if (shouldRestart) { + await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false }); + } } catch (error) { - if ( - error instanceof Error && - error.message.includes("Script not found: ao-update.sh") - ) { + if (error instanceof Error && error.message.includes("Script not found: ao-update.sh")) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (git) failed: ao-update.sh missing from bundled assets`, + data: { method: "git", reason: "script_missing" }, + }); console.error( chalk.red( "ao-update.sh is missing from the bundled assets. " + @@ -240,10 +385,26 @@ async function handleGitUpdate(opts: { "If you're on a package install, reinstall the package.", ), ); + if (shouldRestart) { + await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false }); + } process.exit(1); } + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (git) failed`, + data: { + method: "git", + errorMessage: error instanceof Error ? error.message : String(error), + }, + }); console.error(error instanceof Error ? error.message : String(error)); + if (shouldRestart) { + await restartAoAfterUpdate(lifecyclePlan, { restore: opts.restore !== false }); + } process.exit(1); } } @@ -252,7 +413,7 @@ async function handleGitUpdate(opts: { // npm / pnpm / bun global install // --------------------------------------------------------------------------- -async function handleNpmUpdate(method: InstallMethod): Promise { +async function handleNpmUpdate(method: InstallMethod, opts: { restore: boolean }): Promise { const channel = resolveUpdateChannel(); // Snapshot the previously cached channel BEFORE we force a refresh, so we @@ -260,9 +421,34 @@ async function handleNpmUpdate(method: InstallMethod): Promise { // 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); } @@ -274,9 +460,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise { // is the right UX for a channel transition, and the install command we'd // run is genuinely different even if the version-compare says "no". const isChannelSwitch = - !info.isOutdated && - previousChannel !== undefined && - previousChannel !== channel; + !info.isOutdated && previousChannel !== undefined && previousChannel !== channel; // First-channel opt-in. previousChannel === undefined means we've never // installed via the auto-updater. A user who just ran `ao config set @@ -305,9 +489,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise { console.log(`Channel: ${chalk.cyan(channel)}`); if (isChannelSwitch) { console.log( - chalk.yellow( - `\nChannel switch detected: was on ${previousChannel}, now ${channel}.`, - ), + chalk.yellow(`\nChannel switch detected: was on ${previousChannel}, now ${channel}.`), ); console.log( chalk.dim( @@ -326,15 +508,12 @@ async function handleNpmUpdate(method: InstallMethod): Promise { const command = getUpdateCommand(method, channel); const apiInvoked = isApiInvoked(); const interactive = isTTY() && !apiInvoked; + const lifecyclePlan = await getUpdateLifecyclePlan(); - // Non-interactive path: API-invoked OR piped output. We still gate on the - // active-session guard (refusing returns true/false), but we never bail - // out just because there's no terminal — the dashboard's "Update" click - // must actually install. The only thing we skip is the confirm prompt. - if (!(await ensureNoActiveSessions())) { - process.exit(1); - } - + // Non-interactive path: API-invoked OR piped output. We still plan the + // stop/start lifecycle, but we never bail out just because there's no + // terminal — the dashboard's "Update" click must actually install. The + // only thing we skip is the confirm prompt. if (interactive) { // Soft auto-install: when the user has opted into stable or nightly we // skip the confirm prompt — they've already said "keep me on this channel." @@ -346,10 +525,7 @@ async function handleNpmUpdate(method: InstallMethod): Promise { isChannelSwitch || isFirstChannelOptIn ? `Switch to ${channel} via ${chalk.cyan(command)}?` : `Run ${chalk.cyan(command)}?`; - const confirmed = await promptConfirm( - promptText, - !(isChannelSwitch || isFirstChannelOptIn), - ); + const confirmed = await promptConfirm(promptText, !(isChannelSwitch || isFirstChannelOptIn)); if (!confirmed) return; } else { console.log(chalk.dim(`Updating: ${command}`)); @@ -364,18 +540,95 @@ async function handleNpmUpdate(method: InstallMethod): Promise { return; } - const exitCode = await runNpmInstall(command); - if (exitCode === 0) { - invalidateCache(); - console.log(chalk.green("\nUpdate complete.")); - } else { - process.exit(exitCode); + const shouldRestart = await pauseAoForUpdate(lifecyclePlan); + const installResult = await runNpmInstall(command); + if (installResult.exitCode !== 0) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: install command exited non-zero`, + data: { + method, + command, + exitCode: installResult.exitCode, + classification: classifyInstallFailure(installResult.output).kind, + }, + }); + printInstallFailure({ + method, + command, + channel, + currentVersion: info.currentVersion, + exitCode: installResult.exitCode, + output: installResult.output, + }); + if (shouldRestart) { + console.log(chalk.dim("\nRestarting AO with the existing installation...")); + await restartAoAfterUpdate(lifecyclePlan, opts); + } + process.exit(1); } + + const verification = await verifyInstalledVersion(info.latestVersion, info.currentVersion); + if (!verification.ok) { + recordActivityEvent({ + source: "cli", + kind: "cli.update_failed", + level: "error", + summary: `ao update (${method}) failed: installed version verification failed`, + data: { + method, + command, + expectedVersion: info.latestVersion, + actualVersion: verification.actualVersion, + output: verification.output, + }, + }); + console.error(chalk.red(`\nAO was not verified after install.`)); + console.error(chalk.yellow(verification.message)); + console.error(chalk.dim(`Expected: ${info.latestVersion}`)); + console.error(chalk.dim(`Current before update: ${info.currentVersion}`)); + if (shouldRestart) { + console.log(chalk.dim("\nRestarting AO before exiting...")); + await restartAoAfterUpdate(lifecyclePlan, opts); + } + process.exit(1); + } + + invalidateCache(); + if (shouldRestart) { + await restartAoAfterUpdate(lifecyclePlan, opts); + } + console.log( + chalk.green( + `\nUpdate complete: ${info.currentVersion} → ${verification.actualVersion}.` + + (shouldRestart ? " AO restarted." : ""), + ), + ); } -function runNpmInstall(command: string): Promise { +interface CommandResult { + exitCode: number; + output: string; +} + +function runNpmInstall(command: string): Promise { const [cmd, ...args] = command.split(" "); - return new Promise((resolveExit, reject) => { + return runCommandCapture(cmd!, args, { echo: true }).then((result) => { + if (result.exitCode !== 0) { + console.error(chalk.yellow(`\n${cmd} exited with code ${result.exitCode}.`)); + } + return result; + }); +} + +function runCommandCapture( + cmd: string, + args: string[], + opts: { echo?: boolean } = {}, +): Promise { + return new Promise((resolveExit) => { // `shell: isWindows()` is required so PATHEXT gets consulted on Windows — // npm/pnpm/bun install as `*.cmd` shims, and Node.js does not look at // PATHEXT for non-shell spawns, so a bare `npm` / `pnpm` / `bun` lookup @@ -383,25 +636,162 @@ function runNpmInstall(command: string): Promise { // keeps the shell window from flashing. Same fix that landed for the // dashboard's /api/update spawn in commit 9f29131d. const child = spawn(cmd!, args, { - stdio: "inherit", + stdio: ["inherit", "pipe", "pipe"], shell: isWindows(), windowsHide: true, }); - child.on("error", reject); + let output = ""; + const collect = (chunk: Buffer | string, stream: NodeJS.WriteStream): void => { + const text = chunk.toString(); + output += text; + if (opts.echo) stream.write(chunk); + }; + child.stdout?.on("data", (chunk: Buffer | string) => collect(chunk, process.stdout)); + child.stderr?.on("data", (chunk: Buffer | string) => collect(chunk, process.stderr)); + child.on("error", (error) => { + output += `${error.name}: ${error.message}`; + resolveExit({ exitCode: 1, output }); + }); child.on("exit", (code, signal) => { if (signal) { - resolveExit(1); + resolveExit({ exitCode: 1, output: `${output}\nTerminated by signal ${signal}` }); return; } - if (code !== 0) { - console.error(chalk.yellow(`\n${cmd} exited with code ${code}.`)); - } - resolveExit(code ?? 1); + resolveExit({ exitCode: code ?? 1, output }); }); }); } +interface VerificationResult { + ok: boolean; + actualVersion?: string; + output: string; + message: string; +} + +async function verifyInstalledVersion( + expectedVersion: string, + previousVersion: string, +): Promise { + const result = await runCommandCapture("ao", ["--version"]); + const output = result.output.trim(); + const actualVersion = parseAoVersion(output); + + if (result.exitCode !== 0) { + return { + ok: false, + output, + message: `\`ao --version\` failed with exit ${result.exitCode}.`, + }; + } + if (!actualVersion) { + return { + ok: false, + output, + message: `Could not parse \`ao --version\` output: ${output || ""}`, + }; + } + if (actualVersion !== expectedVersion) { + return { + ok: false, + actualVersion, + output, + message: + actualVersion === previousVersion + ? `The install command exited successfully, but AO is still on ${previousVersion}.` + : `The install command exited successfully, but AO reports ${actualVersion}.`, + }; + } + + return { ok: true, actualVersion, output, message: "verified" }; +} + +function parseAoVersion(output: string): string | undefined { + const match = output.match(/\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/); + return match?.[1]; +} + +function classifyInstallFailure(output: string): { kind: string; guidance: string } { + if (/ERR_PNPM_UNEXPECTED_VIRTUAL_STORE/i.test(output)) { + return { + kind: "pnpm_virtual_store", + guidance: + "pnpm's global store metadata is inconsistent. Try `pnpm store prune`, then retry `ao update`. " + + "If pnpm remains stuck, use the npm fallback below.", + }; + } + if (/(?:EACCES|EPERM|permission denied|access denied)/i.test(output)) { + return { + kind: "permission", + guidance: + "The package manager could not write to the global install location. Fix your npm/pnpm global prefix permissions, or retry from a shell with access to that directory.", + }; + } + if (/(?:ENETUNREACH|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network|socket hang up)/i.test(output)) { + return { + kind: "network", + guidance: + "The registry request failed due to a network error. Check connectivity/VPN/proxy settings and retry `ao update`.", + }; + } + if ( + /(?:lockfile|ERR_PNPM_LOCKFILE|ERR_PNPM_OUTDATED_LOCKFILE|ERR_PNPM_BROKEN_LOCKFILE)/i.test( + output, + ) + ) { + return { + kind: "lockfile", + guidance: + "pnpm reported lockfile state problems. Clear the affected global install metadata or retry with the npm fallback below.", + }; + } + if ( + /(?:registry|ERR_PNPM_FETCH|ERR_PNPM_META_FETCH_FAIL|E401|E403|E404|404 Not Found|401 Unauthorized|403 Forbidden)/i.test( + output, + ) + ) { + return { + kind: "registry", + guidance: + "The npm registry rejected or failed the package request. Check registry configuration, auth tokens, and the selected AO update channel.", + }; + } + return { + kind: "unknown", + guidance: + "The package manager failed before AO could verify the new version. Retry `ao update` after addressing the package-manager error below.", + }; +} + +function printInstallFailure(opts: { + method: InstallMethod; + command: string; + channel: ReturnType; + currentVersion: string; + exitCode: number; + output: string; +}): void { + const classification = classifyInstallFailure(opts.output); + const fallbackCommand = getUpdateCommand("npm-global", opts.channel); + + console.error( + chalk.red(`\nAO was not updated. You are still on version ${opts.currentVersion}.`), + ); + console.error( + chalk.yellow( + `The package manager (${opts.method.replace("-global", "")}) failed with exit ${opts.exitCode}.`, + ), + ); + console.error(chalk.yellow(classification.guidance)); + console.error(chalk.dim(`\nTo retry: ao update`)); + if (opts.command !== fallbackCommand) { + console.error(chalk.dim(`You can also try: ${fallbackCommand}`)); + } + console.error(chalk.dim("\nPackage manager output:")); + console.error(opts.output.trim() || ""); +} + // --------------------------------------------------------------------------- // homebrew install (notice only) // --------------------------------------------------------------------------- @@ -415,9 +805,7 @@ async function handleHomebrewUpdate(): Promise { console.log(`Latest version: ${chalk.green(info.latestVersion)}`); } console.log(); - console.log( - `Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`, - ); + console.log(`Homebrew installs are managed by brew. Run:\n ${chalk.cyan("brew upgrade ao")}`); console.log( chalk.dim( " (AO does not auto-install for brew installs because it would clobber brew's symlinks.)", 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..a526f9a5b 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -20,12 +20,16 @@ terminalPort: 14800 # Optional terminal WebSocket port override directTerminalPort: 14801 # Optional direct terminal WebSocket port override readyThresholdMs: 300000 # Ms before "ready" becomes "idle" (default: 5 min) +observability: + logLevel: warn # debug | info | warn | error + stderr: false # Mirror structured logs to stderr + # ── Default plugins ───────────────────────────────────────────────── # These apply to all projects unless overridden per-project. defaults: runtime: tmux # tmux | process - agent: claude-code # claude-code | aider | codex | cursor | kimicode | opencode + agent: claude-code # claude-code | aider | codex | cursor | kimicode | grok | opencode workspace: worktree # worktree | clone notifiers: - desktop # desktop | discord | slack | webhook | composio | openclaw @@ -110,6 +114,12 @@ projects: notifiers: desktop: plugin: desktop + # Run 'ao setup desktop' on macOS to use AO Notifier.app + # backend: ao-app + dashboard: + plugin: dashboard + # Run 'ao setup dashboard' to retain notifications in the web dashboard + # limit: 50 slack: plugin: slack # Requires SLACK_WEBHOOK_URL env var @@ -119,8 +129,42 @@ notifiers: openclaw: plugin: openclaw # url: http://127.0.0.1:18789/hooks/agent - # token: \${OPENCLAW_HOOKS_TOKEN} + # openclawConfigPath: ~/.openclaw/openclaw.json + # OpenClaw owns hooks.token in openclaw.json # Run 'ao setup openclaw' for guided configuration + composio: + plugin: composio + # Run 'ao setup composio' to connect Slack through Composio + # userId: aoagent + # connectedAccountId: ca_... + # channelName: "#agents" + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY + # toolVersion: "20260508_00" # optional Slack override + composio-discord: + plugin: composio + # Run 'ao setup composio-discord' for Discord webhook mode through Composio + # defaultApp: discord + # mode: webhook + # webhookUrl: https://discord.com/api/webhooks/... + # userId: aoagent + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY + composio-discord-bot: + plugin: composio + # Run 'ao setup composio-discord-bot' for Discord bot mode through Composio + # defaultApp: discord + # mode: bot + # channelId: "1234567890" + # userId: aoagent + # connectedAccountId: ca_... + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY + composio-mail: + plugin: composio + # Run 'ao setup composio-mail' to connect Gmail through Composio + # defaultApp: gmail + # emailTo: alerts@example.com + # userId: aoagent + # connectedAccountId: ca_... + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY # ── Notification routing (optional) ───────────────────────────────── # Route notifications by priority level. @@ -138,12 +182,12 @@ notificationRouting: # ── Available plugins ─────────────────────────────────────────────── # -# Agent: claude-code, aider, codex, cursor, kimicode, opencode +# Agent: claude-code, aider, codex, cursor, kimicode, grok, opencode # Runtime: tmux, process # Workspace: worktree, clone # SCM: github, gitlab # Tracker: github, linear, gitlab -# Notifier: desktop, discord, slack, webhook, composio, openclaw +# Notifier: dashboard, desktop, discord, slack, webhook, composio, openclaw # Terminal: iterm2, web `.trim(); } 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/dashboard-url.ts b/packages/cli/src/lib/dashboard-url.ts new file mode 100644 index 000000000..a27c2c2a2 --- /dev/null +++ b/packages/cli/src/lib/dashboard-url.ts @@ -0,0 +1,24 @@ +/** + * Returns the user-facing base URL of the dashboard. + * + * When `AO_PUBLIC_URL` is set in the environment, AO is being fronted by a + * reverse proxy (e.g. when running inside a remote dev container or behind + * Caddy/nginx). All console output, `ao open` browser launches, and session + * URLs surfaced to humans should use that public URL instead of localhost. + * + * The trailing slash is stripped for consistency so callers can append paths + * without producing `//`. + * + * Internal IPC (the daemon hitting its own dashboard's API) is intentionally + * **not** routed through this helper — those calls always use localhost since + * they happen on the same host as the dashboard process. + * + * @param port - the local dashboard port; only used in the localhost fallback + */ +export function dashboardUrl(port: number): string { + const publicUrl = process.env.AO_PUBLIC_URL?.trim(); + if (publicUrl) { + return publicUrl.replace(/\/+$/, ""); + } + return `http://localhost:${port}`; +} 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/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index b29f7ee6f..c52c7d4be 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -20,6 +20,7 @@ const AGENT_PLUGINS: Array<{ name: string; pkg: string }> = [ { name: "codex", pkg: "@aoagents/ao-plugin-agent-codex" }, { name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" }, { name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" }, + { name: "grok", pkg: "@aoagents/ao-plugin-agent-grok" }, { name: "opencode", pkg: "@aoagents/ao-plugin-agent-opencode" }, ]; 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/plugins.ts b/packages/cli/src/lib/plugins.ts index fc17acede..3e7f2f311 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -4,6 +4,7 @@ import codexPlugin from "@aoagents/ao-plugin-agent-codex"; import aiderPlugin from "@aoagents/ao-plugin-agent-aider"; import cursorPlugin from "@aoagents/ao-plugin-agent-cursor"; import kimicodePlugin from "@aoagents/ao-plugin-agent-kimicode"; +import grokPlugin from "@aoagents/ao-plugin-agent-grok"; import opencodePlugin from "@aoagents/ao-plugin-agent-opencode"; import githubSCMPlugin from "@aoagents/ao-plugin-scm-github"; @@ -13,6 +14,7 @@ const agentPlugins: Record = { aider: aiderPlugin, cursor: cursorPlugin, kimicode: kimicodePlugin, + grok: grokPlugin, opencode: opencodePlugin, }; 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/update-check.ts b/packages/cli/src/lib/update-check.ts index 80ec8f586..7dc72019f 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -16,6 +16,7 @@ import { promisify } from "node:util"; import { getInstalledAoVersion, getUpdateCheckCachePath, + isVersionOutdatedForChannel as coreIsVersionOutdatedForChannel, isVersionOutdated as coreIsVersionOutdated, loadGlobalConfig, type UpdateChannel, @@ -181,8 +182,7 @@ export function classifyInstallPath(resolvedPath: string): InstallMethod { if (isPnpmGlobal) return "pnpm-global"; const isNpmGlobal = - resolvedPath.includes("/lib/node_modules/") || - resolvedPath.includes("\\lib\\node_modules\\"); + resolvedPath.includes("/lib/node_modules/") || resolvedPath.includes("\\lib\\node_modules\\"); if (isNpmGlobal) return "npm-global"; return "unknown"; @@ -256,10 +256,7 @@ export function getGitUpdateRef(): string { * a notice so the user runs it themselves — auto-running `npm install -g` * inside a brew prefix overwrites brew's symlinks. */ -export function getUpdateCommand( - method: InstallMethod, - channel: UpdateChannel = "stable", -): string { +export function getUpdateCommand(method: InstallMethod, channel: UpdateChannel = "stable"): string { // "manual" channel maps to "stable" for the install command — the channel // affects when we check, not which tag manual installers should pick. const tag = channel === "nightly" ? "nightly" : "latest"; @@ -284,6 +281,14 @@ export function isManualOnlyInstall(method: InstallMethod): boolean { return method === "homebrew"; } +export function isOutdatedForChannel( + currentVersion: string, + latestVersion: string, + channel: UpdateChannel, +): boolean { + return coreIsVersionOutdatedForChannel(currentVersion, latestVersion, channel); +} + // --------------------------------------------------------------------------- // Cache // --------------------------------------------------------------------------- @@ -493,7 +498,7 @@ export async function checkForUpdate(opts?: { isOutdated: cached.installMethod === "git" ? cached.isOutdated === true - : isVersionOutdated(currentVersion, cached.latestVersion), + : isOutdatedForChannel(currentVersion, cached.latestVersion, channel), installMethod, recommendedCommand, checkedAt: cached.checkedAt, @@ -540,14 +545,16 @@ export async function checkForUpdate(opts?: { currentVersionAtCheck: currentVersion, installMethod, channel, - isOutdated: isVersionOutdated(currentVersion, latestVersion), + isOutdated: isOutdatedForChannel(currentVersion, latestVersion, channel), }); } return { currentVersion, latestVersion, - isOutdated: latestVersion ? isVersionOutdated(currentVersion, latestVersion) : false, + isOutdated: latestVersion + ? isOutdatedForChannel(currentVersion, latestVersion, channel) + : false, installMethod, recommendedCommand, checkedAt: latestVersion ? now : null, @@ -585,7 +592,7 @@ export function maybeShowUpdateNotice(): void { const isOutdated = installMethod === "git" ? cached.isOutdated === true - : isVersionOutdated(currentVersion, cached.latestVersion); + : isOutdatedForChannel(currentVersion, cached.latestVersion, channel); if (!isOutdated) return; const channelSuffix = channel === "nightly" ? " (nightly)" : ""; 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/CHANGELOG.md b/packages/core/CHANGELOG.md index 31a717031..70ddadfc0 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,84 @@ # @aoagents/ao-core +## 0.9.0 + +### Minor Changes + +- 73bed33: Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620). + - `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature) + - `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body) + - `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body) + - `api.webhook_failed` (error) — outer pipeline crash with `errorMessage` + - `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle + - `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only) + - `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser") + - `ui.terminal_protocol_error` (warn) — invalid mux client message + - `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min + + `api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window. + +- 7d9b862: Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941). + + Claude Code emits a lifecycle hook on every state transition that matters + (`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`, + …). Until now, AO ignored all but one of them and tried to infer the + same information by regex-matching Claude's rendered terminal output — + fragile by construction. Every Claude UI tweak (footer wording, status + verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits + patching the sharpest edges. + + This release pivots: + + **`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per + workspace: + - `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git + side-effects (PR URL, branch, merge status). + - `activity-updater` — new; registered on every hook that carries + activity information (SessionStart, UserPromptSubmit, PreToolUse, + PostToolUse, PostToolUseFailure, PostToolBatch, Notification, + PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop, + PreCompact, PostCompact). The script reads the JSON payload from + stdin, maps `hook_event_name` to an activity state, and appends a + JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`. + + Notification is filtered by `notification_type` so `auth_success` / + `elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket + "Notification → waiting_input" would have regressed here). + + The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of + patterns + `agent.recordActivity`) is retired. `detectActivity` stays on + the Agent interface for other agents but is now a stable `return "idle"` + stub for Claude — the JSONL-backed cascade is the only source of truth + for active / ready / waiting_input / blocked. + + **`@aoagents/ao-core`** extends `ActivityLogEntry.source` and + `ActivitySignalSource` with a `"hook"` value so the new entries are + parseable and their provenance is visible in telemetry. No downstream + consumer needs changes — the cascade has always read whatever source + appeared in the JSONL, and the new tests assert hook-sourced entries + flow through `checkActivityLogState` / `getActivityFallbackState` + identically to terminal-sourced ones. + + Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly + one entry per event and preserves user-installed hooks alongside ours. + Cross-platform: bash + Node (.cjs) variants behave identically against a + shared 52-case scenario table. + +- 6d48022: Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths. +- fcedb25: Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI. +- 94981dc: feat: "Launch Orchestrator (clean context)" action on the orchestrator session page + + Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080. + +### Patch Changes + +- a610601: Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"` → `blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903. + + Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}` → `blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider). + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- d5d0f07: Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic. + ## 0.8.0 ### Minor Changes 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 3ac2696d8..34a08f9f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-core", - "version": "0.8.0", + "version": "0.9.0", "description": "Core library — types, config, session manager, lifecycle manager, event bus", "license": "MIT", "type": "module", 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 6d8288b8d..675441475 100644 --- a/packages/core/src/__tests__/activity-log.test.ts +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -240,6 +240,15 @@ describe("readLastActivityEntry", () => { expect(result).toBeNull(); }); + it("accepts entries with source: hook", async () => { + await appendActivityEntry(tmpDir, "waiting_input", "hook", "PermissionRequest"); + const result = await readLastActivityEntry(tmpDir); + expect(result).not.toBeNull(); + expect(result!.entry.state).toBe("waiting_input"); + expect(result!.entry.source).toBe("hook"); + expect(result!.entry.trigger).toBe("PermissionRequest"); + }); + it("falls back to the previous complete line when a read races a truncated tail", async () => { await mkdir(join(tmpDir, ".ao"), { recursive: true }); const completeEntry: ActivityLogEntry = { 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__/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..2bbb9ca43 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"; @@ -76,6 +76,7 @@ function persistSession( branch: session.branch ?? "main", status: session.status, project: "my-app", + agent: session.metadata["agent"] ?? "mock-agent", runtimeHandle: session.runtimeHandle ?? undefined, ...metaOverrides, }; diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 13b8ef32a..68edb12fe 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -149,6 +149,7 @@ function setupCheck( branch: opts.session.branch ?? "main", status: opts.session.status, project: "my-app", + agent: opts.session.metadata["agent"] ?? "mock-agent", runtimeHandle: opts.session.runtimeHandle ?? undefined, ...opts.metaOverrides, }; @@ -225,6 +226,7 @@ function setupPollCheck( branch: opts.session.branch ?? "main", status: opts.session.status, project: "my-app", + agent: opts.session.metadata["agent"] ?? "mock-agent", runtimeHandle: opts.session.runtimeHandle ?? undefined, ...opts.metaOverrides, }; @@ -530,7 +532,7 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("detecting"); }); - it("uses worker-specific agent fallback when metadata does not persist an agent", async () => { + it("uses persisted session agent even when worker config differs", async () => { const codexAgent: Agent = { ...plugins.agent, name: "codex", @@ -557,13 +559,13 @@ describe("check (single session)", () => { "my-app": { ...config.projects["my-app"], agent: "mock-agent", - worker: { agent: "codex" }, + worker: { agent: "mock-agent" }, }, }, }; const lm = setupCheck("app-1", { - session: makeSession({ status: "working", metadata: {} }), + session: makeSession({ status: "working", metadata: { agent: "codex" } }), registry: registryWithMultipleAgents, configOverride: configWithWorkerAgent, }); @@ -1770,7 +1772,11 @@ describe("check (single session)", () => { expect(notifier.notify).toHaveBeenCalledWith( expect.objectContaining({ type: "reaction.triggered", - data: expect.objectContaining({ reactionKey: "pr-closed" }), + data: expect.objectContaining({ + schemaVersion: 3, + semanticType: "pr.closed", + reaction: expect.objectContaining({ key: "pr-closed" }), + }), }), ); }); @@ -1867,10 +1873,11 @@ describe("reactions", () => { const reactionNotifications = vi.mocked(notifier.notify).mock.calls.filter((call) => { const event = call[0] as { type?: string; data?: Record } | 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 +2846,112 @@ describe("reactions", () => { expect(notifier.notify).toHaveBeenCalledWith( expect.objectContaining({ type: "merge.completed" }), ); + + const summary = readObservabilitySummary(config); + expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.success).toBe(1); + expect( + summary.projects["my-app"]?.recentTraces.some( + (trace) => + trace.operation === "notification.deliver" && + trace.outcome === "success" && + trace.data?.["targetReference"] === "desktop", + ), + ).toBe(true); + }); + + it("records notifier delivery failures without interrupting lifecycle transitions", async () => { + const notifier = createMockNotifier(); + vi.mocked(notifier.notify).mockRejectedValue(new Error("webhook failed")); + const mockSCM = createMockSCM({ + getPRState: vi.fn().mockResolvedValue("merged"), + enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }), + }); + + const registry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "scm") return mockSCM; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + const lm = setupCheck("app-1", { + session: makeSession({ status: "approved", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("merged"); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notification.delivery_failed", + level: "warn", + data: expect.objectContaining({ + eventType: "merge.completed", + targetReference: "desktop", + targetPlugin: "desktop", + }), + }), + ); + + const summary = readObservabilitySummary(config); + expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1); + expect(summary.projects["my-app"]?.health["notification.delivery.desktop"]?.status).toBe( + "warn", + ); + }); + + it("records missing notifier targets as delivery failures", async () => { + const mockSCM = createMockSCM({ + getPRState: vi.fn().mockResolvedValue("merged"), + enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }), + }); + const configWithMissingNotifier: OrchestratorConfig = { + ...config, + notificationRouting: { + ...config.notificationRouting, + action: ["missing"], + }, + }; + + const registry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "scm") return mockSCM; + return null; + }), + }; + + const lm = setupCheck("app-1", { + session: makeSession({ status: "approved", pr: makePR() }), + registry, + configOverride: configWithMissingNotifier, + }); + + await lm.check("app-1"); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notification.target_missing", + level: "warn", + data: expect.objectContaining({ + eventType: "merge.completed", + targetReference: "missing", + targetPlugin: "missing", + }), + }), + ); + + const summary = readObservabilitySummary(configWithMissingNotifier); + expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1); }); it("resolves notifier aliases from notificationRouting before dispatch", async () => { @@ -3344,22 +3457,34 @@ describe("reactions", () => { // Reach escalated state: attempt 1 → send, attempt 2 → escalate await lm.check("app-1"); // pr_open → ci_failed: attempt 1, send vi.mocked(mockSessionManager.send).mockClear(); - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // ci_failed → pr_open - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); // pr_open → ci_failed: attempt 2 → escalate - expect(notifier.notify).toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" })); + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.escalated" }), + ); vi.mocked(notifier.notify).mockClear(); // ONE passing poll (stableCount = 1, not enough) - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // Next CI failure: tracker still escalated → short-circuit - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); expect(mockSessionManager.send).not.toHaveBeenCalled(); - expect(notifier.notify).not.toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" })); + expect(notifier.notify).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.escalated" }), + ); }); it("pending CI does not count toward ci-failed tracker resolution", async () => { @@ -3401,12 +3526,16 @@ describe("reactions", () => { vi.mocked(mockSessionManager.send).mockClear(); // CI goes pending (agent pushed a fix, new run started): ci_failed → pr_open - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "pending" }), + ); await lm.check("app-1"); // stableCount must NOT increment await lm.check("app-1"); // two pending polls — must NOT clear tracker // CI fails again (run completed failing): pr_open → ci_failed, attempt 2 — send - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); // If pending had wrongly cleared the tracker, this would be attempt 1 (fresh), not attempt 2. // Attempt 2 ≤ retries:2 → sends to agent (not escalates) @@ -3414,10 +3543,14 @@ describe("reactions", () => { vi.mocked(mockSessionManager.send).mockClear(); // CI goes pending again, then failing — attempt 3 > retries:2 → escalate - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "pending" }), + ); await lm.check("app-1"); // pending: no clear await lm.check("app-1"); // pending: no clear - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); expect(mockSessionManager.send).not.toHaveBeenCalled(); // escalated, not sent to agent }); @@ -3461,25 +3594,35 @@ describe("reactions", () => { // Reach escalated state: attempt 1 → send, attempt 2 → escalate await lm.check("app-1"); // attempt 1, send vi.mocked(mockSessionManager.send).mockClear(); - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // ci_failed → pr_open - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); // attempt 2 → escalate vi.mocked(notifier.notify).mockClear(); // CI goes pending (new run) — stableCount stays 0, does NOT progress toward resolution - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "pending" }), + ); await lm.check("app-1"); await lm.check("app-1"); await lm.check("app-1"); // many pending polls — stableCount never reaches threshold // CI finally passes (2 stable polls) → tracker cleared - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // stableCount = 1 await lm.check("app-1"); // stableCount = 2 → clearReactionTracker // Next CI failure gets fresh budget: attempt 1, send - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); expect(mockSessionManager.send).toHaveBeenCalledTimes(1); expect(notifier.notify).not.toHaveBeenCalledWith( @@ -3632,7 +3775,11 @@ describe("pollAll terminal status accounting", () => { .mock.calls.filter((call: unknown[]) => { const event = call[0] as Record | 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 +4357,19 @@ describe("event enrichment", () => { expect.objectContaining({ type: "pr.closed", data: expect.objectContaining({ - context: expect.objectContaining({ + schemaVersion: 3, + subject: expect.objectContaining({ pr: expect.objectContaining({ url: "https://github.com/org/repo/pull/42", number: 42, }), branch: "feat/test-123", }), - schemaVersion: 2, + transition: expect.objectContaining({ + kind: "pr_state", + from: "none", + to: "closed", + }), }), }), ); @@ -4257,11 +4409,13 @@ describe("event enrichment", () => { expect.objectContaining({ type: "pr.closed", data: expect.objectContaining({ - context: expect.objectContaining({ - issueId: "INT-123", - issueTitle: "Fix login bug", + schemaVersion: 3, + subject: expect.objectContaining({ + issue: { + id: "INT-123", + title: "Fix login bug", + }, }), - schemaVersion: 2, }), }), ); @@ -4305,11 +4459,15 @@ describe("event enrichment", () => { expect.objectContaining({ type: "session.needs_input", data: expect.objectContaining({ - context: expect.objectContaining({ - pr: null, - issueId: "INT-456", + schemaVersion: 3, + subject: expect.objectContaining({ + issue: { id: "INT-456" }, + }), + transition: expect.objectContaining({ + kind: "session_status", + from: "working", + to: "needs_input", }), - schemaVersion: 2, }), }), ); 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__/platform.test.ts b/packages/core/src/__tests__/platform.test.ts index 06667db02..0e3fcfb80 100644 --- a/packages/core/src/__tests__/platform.test.ts +++ b/packages/core/src/__tests__/platform.test.ts @@ -44,6 +44,14 @@ describe("platform adapter", () => { }); }); + describe("getNodePtyPrebuildsSubdir", () => { + it("centralizes node-pty prebuild platform/arch naming", async () => { + setPlatform("darwin"); + const mod = await import("../platform.js"); + expect(mod.getNodePtyPrebuildsSubdir()).toBe(`darwin-${process.arch}`); + }); + }); + describe("getShell", () => { it("always returns /bin/sh on unix (ignores $SHELL)", async () => { setPlatform("linux"); 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..880e96c18 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -151,11 +151,13 @@ describe("loadBuiltins", () => { const fakeClaudeCode = makePlugin("agent", "claude-code"); const fakeCodex = makePlugin("agent", "codex"); + const fakeGrok = makePlugin("agent", "grok"); const fakeOpenCode = makePlugin("agent", "opencode"); await registry.loadBuiltins(undefined, async (pkg: string) => { if (pkg === "@aoagents/ao-plugin-agent-claude-code") return fakeClaudeCode; if (pkg === "@aoagents/ao-plugin-agent-codex") return fakeCodex; + if (pkg === "@aoagents/ao-plugin-agent-grok") return fakeGrok; if (pkg === "@aoagents/ao-plugin-agent-opencode") return fakeOpenCode; throw new Error(`Not found: ${pkg}`); }); @@ -163,10 +165,12 @@ describe("loadBuiltins", () => { const agents = registry.list("agent"); expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" })); expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" })); + expect(agents).toContainEqual(expect.objectContaining({ name: "grok", slot: "agent" })); expect(agents).toContainEqual(expect.objectContaining({ name: "opencode", slot: "agent" })); expect(registry.get("agent", "codex")).not.toBeNull(); expect(registry.get("agent", "claude-code")).not.toBeNull(); + expect(registry.get("agent", "grok")).not.toBeNull(); expect(registry.get("agent", "opencode")).not.toBeNull(); }); @@ -348,6 +352,68 @@ describe("loadBuiltins", () => { }); }); + it("passes configPath for named notifier references without explicit config", async () => { + const registry = createPluginRegistry(); + const fakeDashboard = makePlugin("notifier", "dashboard"); + const cfg = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + defaults: { + runtime: "tmux", + agent: "codex", + workspace: "worktree", + notifiers: ["dashboard"], + }, + notificationRouting: { + urgent: ["dashboard"], + action: [], + warning: [], + info: [], + }, + notifiers: {}, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeDashboard.create).toHaveBeenCalledWith({ + configPath: "/test/config.yaml", + }); + expect( + registry.get<{ _config: Record }>("notifier", "dashboard")?._config, + ).toEqual({ + configPath: "/test/config.yaml", + }); + }); + + it("does not create an implicit notifier registration over a conflicting explicit entry", async () => { + const registry = createPluginRegistry(); + const fakeDashboard = makePlugin("notifier", "dashboard"); + const cfg = makeOrchestratorConfig({ + defaults: { + runtime: "tmux", + agent: "codex", + workspace: "worktree", + notifiers: ["dashboard"], + }, + notifiers: { + dashboard: { + plugin: "webhook", + url: "https://hooks.example.com/dashboard", + }, + }, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeDashboard.create).not.toHaveBeenCalled(); + expect(registry.get("notifier", "dashboard")).toBeNull(); + }); + it("strips package loading metadata from notifier config", async () => { const registry = createPluginRegistry(); const fakeWebhook = makePlugin("notifier", "webhook"); @@ -427,7 +493,7 @@ describe("loadBuiltins", () => { throw new Error(`Not found: ${pkg}`); }); - expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined); + expect(fakeOpenClaw.create).not.toHaveBeenCalled(); expect(fakeWebhook.create).toHaveBeenCalledWith({ url: "http://127.0.0.1:8787/hook", retries: 3, diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 307ef55bb..6e489a8cb 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -103,6 +103,7 @@ function makeAssessment(overrides: Partial = {}): RecoveryAs metadataStatus: "working", rawMetadata: { project: "app", + agent: "claude-code", branch: "feat/test", issue: "123", pr: "https://github.com/org/repo/pull/42", @@ -169,6 +170,7 @@ describe("recoverSession", () => { expect(result.session?.restoredAt).toBeInstanceOf(Date); expect(metadata?.["restoredAt"]).toBeDefined(); expect(metadata?.["recoveredAt"]).toBeUndefined(); + expect(metadata?.["agent"]).toBe("claude-code"); }); it("preserves project ownership when legacy metadata omits the project field", async () => { 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__/recovery-validator.test.ts b/packages/core/src/__tests__/recovery-validator.test.ts index c0e418152..2ceb85455 100644 --- a/packages/core/src/__tests__/recovery-validator.test.ts +++ b/packages/core/src/__tests__/recovery-validator.test.ts @@ -119,6 +119,7 @@ describe("recovery validator", () => { const assessment = await validateSession(scanned, config, registry); expect(assessment.agentProcessRunning).toBe(true); + expect(assessment.rawMetadata["agent"]).toBe("codex"); expect(mockOrchestratorAgent.isProcessRunning).toHaveBeenCalled(); expect(mockWorkerAgent.isProcessRunning).not.toHaveBeenCalled(); }); 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/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index 0faf93c79..8e2724cd6 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -598,6 +598,7 @@ describe("remap", () => { expect(mapped).toBe("ses_project_agent"); const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["agent"]).toBe("opencode"); expect(meta?.["opencodeSessionId"]).toBe("ses_project_agent"); }); }); diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index 17300fca6..39bb68b60 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -64,6 +64,21 @@ describe("list", () => { expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]); }); + it("backfills missing legacy agent metadata on read", async () => { + writeMetadata(sessionsDir, "app-1", { + worktree: "/tmp/w1", + branch: "feat/a", + status: "working", + project: "my-app", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list(); + + expect(sessions[0]?.metadata["agent"]).toBe("mock-agent"); + expect(readMetadataRaw(sessionsDir, "app-1")?.["agent"]).toBe("mock-agent"); + }); + 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, @@ -474,6 +489,7 @@ describe("list", () => { branch: "a", status: "working", project: "my-app", + agent: "mock-agent", runtimeHandle, lifecycle, }); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index d92c1a4f1..d06dbbe3a 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1113,6 +1113,18 @@ describe("spawn", () => { expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); }); + it("delivers prompt after launch for post-launch prompt agents", async () => { + (mockAgent as Agent & { promptDelivery: "post-launch" }).promptDelivery = "post-launch"; + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); + + expect(mockRuntime.sendMessage).toHaveBeenCalledWith( + makeHandle("rt-1"), + expect.stringContaining("Fix the bug"), + ); + }); + it("writes worker system prompt to file and passes only explicit task prompt to agent", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -1444,6 +1456,17 @@ describe("spawn", () => { expect(meta?.["displayName"]).toBe("Add rate limiting to /api/upload"); }); + it("strips a markdown heading marker from prompt-derived displayName", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + await sm.spawn({ + projectId: "my-app", + prompt: "### Add rate limiting to /api/upload\n\nUse a sliding-window counter.", + }); + + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta?.["displayName"]).toBe("Add rate limiting to /api/upload"); + }); + it("truncates long displayName values with an ellipsis", async () => { const longPrompt = "Implement a comprehensive rate-limiter that supports sliding windows, token buckets, and per-route overrides with distributed counters"; @@ -2385,6 +2408,18 @@ describe("spawn", () => { expect(readFileSync(promptFile, "utf-8")).toBe("You are the orchestrator."); }); + it("delivers only a begin trigger after launch for post-launch orchestrator agents", async () => { + (mockAgent as Agent & { promptDelivery: "post-launch" }).promptDelivery = "post-launch"; + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ + projectId: "my-app", + systemPrompt: "You are the orchestrator.", + }); + + expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Begin."); + }); + it("persists displayName derived from the orchestrator system prompt", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); 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/__tests__/version-compare.test.ts b/packages/core/src/__tests__/version-compare.test.ts index 7eeedd9e4..859a49f32 100644 --- a/packages/core/src/__tests__/version-compare.test.ts +++ b/packages/core/src/__tests__/version-compare.test.ts @@ -1,12 +1,47 @@ import { describe, it, expect } from "vitest"; -import { isVersionOutdated } from "../version-compare.js"; +import { isVersionOutdated, isVersionOutdatedForChannel } from "../version-compare.js"; // Both `packages/cli/src/lib/update-check.ts` and // `packages/web/src/app/api/version/route.ts` import this implementation // from core. These tests are the single source of truth for the comparison // rules — if either consumer's behavior diverges, fix the consumer, not this. +describe("isVersionOutdatedForChannel", () => { + it("treats nightly dist-tag changes as updates by identity in both lexical directions", () => { + expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe( + true, + ); + expect(isVersionOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe( + true, + ); + expect( + isVersionOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"), + ).toBe(true); + }); + + it("does not update nightly when the exact dist-tag version is already installed", () => { + expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe( + false, + ); + }); + + it("uses semver for stable-to-nightly channel switches", () => { + expect(isVersionOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false); + expect(isVersionOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true); + expect(isVersionOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false); + }); + + it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => { + expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true); + }); + + it("keeps stable comparisons numeric instead of lexical", () => { + expect(isVersionOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true); + expect(isVersionOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true); + }); +}); + describe("isVersionOutdated (shared core implementation)", () => { describe("numeric major/minor/patch", () => { it("treats lower major as older", () => { 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 56a1e02c3..a661387eb 100644 --- a/packages/core/src/activity-log.ts +++ b/packages/core/src/activity-log.ts @@ -35,7 +35,7 @@ export function getActivityLogPath(workspacePath: string): string { export async function appendActivityEntry( workspacePath: string, state: ActivityState, - source: "terminal" | "native", + source: "terminal" | "native" | "hook", trigger?: string, ): Promise { const logPath = getActivityLogPath(workspacePath); @@ -98,7 +98,7 @@ export async function readLastActivityEntry( const record = parsed as Record; const validStates = new Set(["active", "ready", "idle", "waiting_input", "blocked", "exited"]); - const validSources = new Set(["terminal", "native"]); + const validSources = new Set(["terminal", "native", "hook"]); if ( typeof record.ts !== "string" || typeof record.state !== "string" || 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/agent-selection.ts b/packages/core/src/agent-selection.ts index e017e6e62..7878d564d 100644 --- a/packages/core/src/agent-selection.ts +++ b/packages/core/src/agent-selection.ts @@ -29,6 +29,31 @@ export function resolveSessionRole( : "worker"; } +/** + * Resolve the agent identity for a session metadata record. Normalized Session + * objects are expected to carry metadata.agent; fallback resolution exists only + * for legacy raw metadata read/repair boundaries. + */ +export function resolveAgentSelectionForSession(params: { + sessionId: string; + metadata?: Record; + project: ProjectConfig; + defaults: DefaultPlugins; + allSessionPrefixes?: string[]; +}): ResolvedAgentSelection { + return resolveAgentSelection({ + role: resolveSessionRole( + params.sessionId, + params.metadata, + params.project.sessionPrefix, + params.allSessionPrefixes, + ), + project: params.project, + defaults: params.defaults, + persistedAgent: params.metadata?.["agent"], + }); +} + export function resolveAgentSelection(params: { role: SessionRole; project: ProjectConfig; 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/global-config.ts b/packages/core/src/global-config.ts index cda4ca625..f5840cc39 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`; @@ -205,6 +206,13 @@ export const GlobalConfigSchema = z updateChannel: UpdateChannelSchema.optional().catch(undefined), /** Override path-based install detection. Optional. */ installMethod: InstallMethodOverrideSchema.optional().catch(undefined), + /** 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({ @@ -347,6 +355,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); @@ -465,6 +479,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) { @@ -494,24 +571,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); } @@ -836,6 +896,7 @@ export function resolveProjectIdentity( defaultBranch: string; sessionPrefix: string; resolveError?: string; + resolveErrorKind?: ProjectResolveErrorKind; }) | null { const entry = globalConfig.projects[projectId] as @@ -914,15 +975,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 } : {}), }; } @@ -991,6 +1085,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) @@ -1081,6 +1177,10 @@ export function createDefaultGlobalConfig(): GlobalConfig { return { port: 3000, readyThresholdMs: 300_000, + 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 d0527ba1b..9d5b16e5a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,8 +52,62 @@ export { listMetadata, } from "./metadata.js"; export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js"; +export { + resolveAgentSelection, + resolveAgentSelectionForSession, + resolveSessionRole, + type ResolvedAgentSelection, + type SessionRole, +} from "./agent-selection.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, @@ -223,6 +277,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, @@ -231,6 +322,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 { @@ -257,6 +354,7 @@ export { getProjectDir, getProjectSessionsDir, getProjectWorktreesDir, + getProjectCodeReviewsDir, getProjectFeedbackReportsDir, getOrchestratorPath, getSessionPath, @@ -286,6 +384,7 @@ export { isMac, isLinux, getDefaultRuntime, + getNodePtyPrebuildsSubdir, getShell, killProcessTree, findPidByPort, @@ -294,6 +393,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, @@ -327,7 +444,7 @@ export { UpdateChannelSchema, InstallMethodOverrideSchema } from "./global-confi // Channel-aware semver comparison shared by the CLI's update-check and the // dashboard's /api/version route. -export { isVersionOutdated } from "./version-compare.js"; +export { isVersionOutdated, isVersionOutdatedForChannel } from "./version-compare.js"; // Cache-layer primitives for the update pipeline. Both the CLI and the // dashboard's /api/version route read the same cache file; centralising the diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index e4193ecbf..3cae2f77e 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -69,7 +69,8 @@ import { } from "./report-watcher.js"; import { createCorrelationId, createProjectObserver } from "./observability.js"; import { resolveNotifierTarget } from "./notifier-resolution.js"; -import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; +import { recordNotificationDelivery } from "./notification-observability.js"; +import { resolveSessionRole } from "./agent-selection.js"; import { DETECTING_MAX_ATTEMPTS, createDetectingDecision, @@ -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 ( @@ -902,20 +910,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const lifecycle = cloneLifecycle(session.lifecycle); const nowIso = new Date().toISOString(); - const allSessionPrefixes = Object.values(config.projects).map((p) => p.sessionPrefix); - const sessionRole = resolveSessionRole( - session.id, - session.metadata, - project.sessionPrefix, - allSessionPrefixes, - ); - const agentName = resolveAgentSelection({ - role: sessionRole, - project, - defaults: config.defaults, - persistedAgent: session.metadata["agent"], - }).agentName; - const agent = registry.get("agent", agentName); + const agentName = session.metadata["agent"]; + const agent = agentName ? registry.get("agent", agentName) : null; const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; let detectedIdleTimestamp: Date | null = null; let idleWasBlocked = false; @@ -1316,16 +1312,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 +1449,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 +1460,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { reactionKey, attempts: tracker.attempts, - durationSinceFirstMs: Date.now() - tracker.firstTriggered.getTime(), + durationSinceFirstMs: durationMs, escalationCause, }, }); @@ -1475,7 +1470,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 +1551,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 +1586,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 +1645,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 +2137,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 +2268,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 +2363,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 +2659,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 +2720,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/platform.ts b/packages/core/src/platform.ts index 5cde1af87..2f4323555 100644 --- a/packages/core/src/platform.ts +++ b/packages/core/src/platform.ts @@ -28,6 +28,10 @@ export function getDefaultRuntime(): "tmux" | "process" { return isWindows() ? "process" : "tmux"; } +export function getNodePtyPrebuildsSubdir(): string { + return `${process.platform}-${process.arch}`; +} + // -- Shell resolution -- interface ShellInfo { diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index d67a0d32d..304d8a893 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; @@ -45,6 +46,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "agent", name: "aider", pkg: "@aoagents/ao-plugin-agent-aider" }, { slot: "agent", name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" }, { slot: "agent", name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" }, + { slot: "agent", name: "grok", pkg: "@aoagents/ao-plugin-agent-grok" }, { slot: "agent", name: "opencode", pkg: "@aoagents/ao-plugin-agent-opencode" }, // Workspaces { slot: "workspace", name: "worktree", pkg: "@aoagents/ao-plugin-workspace-worktree" }, @@ -58,6 +60,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 +81,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 +101,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 +126,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 +174,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 +438,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 +497,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 +535,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 +570,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 +600,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/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 43a9eec87..c9d7728ff 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"; @@ -50,6 +51,12 @@ function buildLifecycleRecoveryPatch( return buildLifecycleMetadataPatch(updated); } +function preserveSessionAgentPatch( + rawMetadata: Record, +): Partial> { + return rawMetadata["agent"] ? { agent: rawMetadata["agent"] } : {}; +} + export async function recoverSession( assessment: RecoveryAssessment, config: OrchestratorConfig, @@ -100,6 +107,7 @@ export async function recoverSession( escalatedAt: now, escalationReason: `Exceeded max recovery attempts (${context.recoveryConfig.maxRecoveryAttempts})`, recoveryCount: String(recoveryCount), + ...preserveSessionAgentPatch(rawMetadata), ...buildLifecycleRecoveryPatch(rawMetadata, { state: "stuck", reason: "probe_failure", @@ -120,6 +128,7 @@ export async function recoverSession( status: preservedStatus, restoredAt: now, recoveryCount: String(recoveryCount), + ...preserveSessionAgentPatch(rawMetadata), }); context.invalidateCache?.(); @@ -146,11 +155,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, }; } } @@ -210,6 +233,7 @@ export async function cleanupSession( status: "terminated", terminatedAt: cleanupAt, terminationReason: "cleanup", + ...preserveSessionAgentPatch(rawMetadata), ...buildLifecycleRecoveryPatch(rawMetadata, { state: "terminated", reason: "auto_cleanup", @@ -269,6 +293,7 @@ export async function escalateSession( status: "stuck", escalatedAt: new Date().toISOString(), escalationReason: reason, + ...preserveSessionAgentPatch(rawMetadata), ...buildLifecycleRecoveryPatch(rawMetadata, { state: "stuck", reason: "probe_failure", 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 p.sessionPrefix), - ), + const agentName = resolveAgentSelectionForSession({ + sessionId, + metadata: rawMetadata, project, defaults: config.defaults, - persistedAgent: rawMetadata["agent"], + allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix), }).agentName; + const normalizedMetadata = rawMetadata["agent"] + ? rawMetadata + : { ...rawMetadata, agent: agentName }; const workspaceName = project.workspace ?? config.defaults.workspace; const runtime = registry.get("runtime", runtimeName); const agent = registry.get("agent", agentName); const workspace = registry.get("workspace", workspaceName); - const workspacePath = rawMetadata["worktree"] || null; - const runtimeHandleStr = rawMetadata["runtimeHandle"]; + const workspacePath = normalizedMetadata["worktree"] || null; + const runtimeHandleStr = normalizedMetadata["runtimeHandle"]; const runtimeHandle = runtimeHandleStr ? safeJsonParse(runtimeHandleStr) : null; - const metadataStatus = validateStatus(rawMetadata["status"]); + const metadataStatus = validateStatus(normalizedMetadata["status"]); const recoveryConfig: RecoveryConfig = { ...DEFAULT_RECOVERY_CONFIG, ...(recoveryConfigInput ?? {}), @@ -120,15 +119,15 @@ export async function validateSession( activity: null, activitySignal: createActivitySignal("unavailable"), lifecycle, - branch: rawMetadata["branch"] ?? null, - issueId: rawMetadata["issue"] ?? null, + branch: normalizedMetadata["branch"] ?? null, + issueId: normalizedMetadata["issue"] ?? null, pr: null, workspacePath, runtimeHandle, agentInfo: null, - createdAt: new Date(rawMetadata["createdAt"] ?? Date.now()), - lastActivityAt: new Date(rawMetadata["lastActivityAt"] ?? Date.now()), - metadata: rawMetadata, + createdAt: new Date(normalizedMetadata["createdAt"] ?? Date.now()), + lastActivityAt: new Date(normalizedMetadata["lastActivityAt"] ?? Date.now()), + metadata: normalizedMetadata, }; const detection = await agent.getActivityState(probeSession, config.readyThresholdMs); agentActivity = detection?.state ?? null; @@ -147,7 +146,7 @@ export async function validateSession( } } - const metadataValid = Object.keys(rawMetadata).length > 0; + const metadataValid = Object.keys(normalizedMetadata).length > 0; const classification = classifySession( runtimeAlive, workspaceExists, @@ -195,7 +194,7 @@ export async function validateSession( agentActivity, metadataValid, metadataStatus, - rawMetadata, + rawMetadata: normalizedMetadata, }; } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 6f51650bc..86807c47b 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -94,7 +94,7 @@ import { import { sessionFromMetadata } from "./utils/session-from-metadata.js"; import { safeJsonParse, validateStatus } from "./utils/validation.js"; import { isGitBranchNameSafe } from "./utils.js"; -import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; +import { resolveAgentSelection, resolveAgentSelectionForSession } from "./agent-selection.js"; import { buildAgentPath, setupPathWrapperWorkspace, @@ -272,7 +272,7 @@ function deriveDisplayName(input: { issueTitle?: string; prompt?: string }): str } if (input.prompt && input.prompt.trim()) { - const line = pickLine(input.prompt); + const line = pickLine(input.prompt).replace(/^#{1,6}\s+/, ""); if (line) return truncate(line); } @@ -538,6 +538,21 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionCache = null; } + function repairSessionAgentMetadataOnRead( + sessionsDir: string, + record: ActiveSessionRecord, + project: ProjectConfig, + ): ActiveSessionRecord { + if (record.raw["agent"]) return record; + + const agent = resolveSelectionForSession(project, record.sessionName, record.raw).agentName; + updateMetadataPreservingMtime(sessionsDir, record.sessionName, { agent }, record.modifiedAt); + return { + ...record, + raw: applyMetadataUpdatesToRaw(record.raw, { agent }), + }; + } + function repairSingleSessionMetadataOnRead( sessionsDir: string, record: ActiveSessionRecord, @@ -599,7 +614,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function repairSessionMetadataOnRead( sessionsDir: string, records: ActiveSessionRecord[], - sessionPrefix?: string, + project: ProjectConfig, ): ActiveSessionRecord[] { const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } })); const duplicatePRAttachments = new Map(); @@ -611,7 +626,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionId: record.sessionName, status: validateStatus(record.raw["status"]), createdAt: record.raw["createdAt"] ? new Date(record.raw["createdAt"]) : undefined, - sessionKind: isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix) + sessionKind: isOrchestratorSessionRecord( + record.sessionName, + record.raw, + project.sessionPrefix, + ) ? "orchestrator" : "worker", }), @@ -626,11 +645,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM record.raw = applyMetadataUpdatesToRaw(record.raw, canonicalUpdates); } - if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) { - record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw; + if (isOrchestratorSessionRecord(record.sessionName, record.raw, project.sessionPrefix)) { + record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, project.sessionPrefix).raw; + record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw; continue; } + record.raw = repairSessionAgentMetadataOnRead(sessionsDir, record, project).raw; + const prUrl = record.raw["pr"]; if (!prUrl) continue; @@ -705,7 +727,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord]; }); - return repairSessionMetadataOnRead(sessionsDir, records, project.sessionPrefix); + return repairSessionMetadataOnRead(sessionsDir, records, project); } function sortSessionIdsForReuse(ids: string[]): string[] { @@ -900,16 +922,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionId: string, metadata: Record, ) { - return resolveAgentSelection({ - role: resolveSessionRole( - sessionId, - metadata, - project.sessionPrefix, - Object.values(config.projects).map((p) => p.sessionPrefix), - ), + return resolveAgentSelectionForSession({ + sessionId, + metadata, project, defaults: config.defaults, - persistedAgent: metadata["agent"], + allSessionPrefixes: Object.values(config.projects).map((p) => p.sessionPrefix), }); } @@ -947,10 +965,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM modifiedAt = undefined; } - const repaired = repairSingleSessionMetadataOnRead( + const repaired = repairSessionAgentMetadataOnRead( sessionsDir, - { sessionName: sessionId, raw, modifiedAt }, - project.sessionPrefix, + repairSingleSessionMetadataOnRead( + sessionsDir, + { sessionName: sessionId, raw, modifiedAt }, + project.sessionPrefix, + ), + project, ); return { raw: repaired.raw, sessionsDir, project, projectId }; @@ -1221,6 +1243,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 }); } } @@ -1235,10 +1269,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; @@ -1295,9 +1332,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), + }, + }); } } @@ -1475,6 +1526,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await plugins.agent.postLaunchSetup(session); } + if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) { + await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt); + } + if ( plugins.agent.name === "opencode" && opencodeIssueSessionStrategy === "reuse" && @@ -1514,14 +1569,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}`); @@ -1582,6 +1704,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 { @@ -1627,6 +1762,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; } @@ -1642,6 +1792,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; } @@ -1651,6 +1814,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; } @@ -1675,6 +1851,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; } @@ -1732,6 +1921,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; } @@ -1800,6 +2005,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM await plugins.agent.postLaunchSetup(session); } + if (plugins.agent.promptDelivery === "post-launch" && orchestratorConfig.systemPrompt) { + // The orchestrator prompt is already passed via systemPromptFile in the launch command. + // Send only a minimal trigger so interactive post-launch agents start without + // receiving their system instructions again as a user message. + await plugins.runtime.sendMessage(handle, "Begin."); + } + if ( plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse" && @@ -1819,6 +2031,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); @@ -1829,6 +2054,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; } @@ -1897,14 +2135,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; } } @@ -2068,20 +2319,44 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM 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 = "detecting"; next.session.reason = "runtime_lost"; next.session.lastTransitionAt = new Date().toISOString(); - next.runtime.state = session.lifecycle!.runtime.state; - next.runtime.reason = session.lifecycle!.runtime.reason; + 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) }, + }); } } @@ -2127,10 +2402,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } - const repaired = repairSingleSessionMetadataOnRead( + const repaired = repairSessionAgentMetadataOnRead( sessionsDir, - { sessionName: sessionId, raw, modifiedAt }, - project.sessionPrefix, + repairSingleSessionMetadataOnRead( + sessionsDir, + { sessionName: sessionId, raw, modifiedAt }, + project.sessionPrefix, + ), + project, ); const session = metadataToSession( @@ -2192,6 +2471,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"]); @@ -2204,8 +2494,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), + }, + }); } } } @@ -2219,8 +2521,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), + }, + }); } } } @@ -2238,8 +2553,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), + }, + }); } } } @@ -2372,9 +2699,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 }, }); } } @@ -2416,11 +2753,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; } @@ -2451,7 +2801,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; @@ -2603,19 +2953,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 => { @@ -2707,29 +3069,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; } } @@ -2930,6 +3315,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); } @@ -2950,9 +3349,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 { @@ -2972,6 +3397,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)}`, @@ -3063,6 +3501,16 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: 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 { @@ -3128,6 +3576,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM updateMetadata(sessionsDir, sessionId, { ...buildLifecycleMetadataPatch(restoredLifecycle), + agent: selection.agentName, restoredAt: now, mergedPendingCleanupSince: "", }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2b7bb6e73..70292d925 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -157,7 +157,7 @@ export const ACTIVITY_STATE = { export type ActivitySignalState = "valid" | "stale" | "null" | "unavailable" | "probe_failure"; -export type ActivitySignalSource = "native" | "terminal" | "runtime" | "none"; +export type ActivitySignalSource = "native" | "terminal" | "hook" | "runtime" | "none"; export interface ActivitySignal { /** Confidence bucket for the activity probe result. */ @@ -183,11 +183,16 @@ export interface ActivityDetection { export interface ActivityLogEntry { /** ISO 8601 timestamp */ ts: string; - /** Activity state derived from terminal output or agent-native data */ + /** Activity state derived from terminal output, agent-native data, or a platform-event hook */ state: ActivityState; - /** What triggered this state classification */ - source: "terminal" | "native"; - /** Raw terminal snippet that caused waiting_input/blocked (for debugging) */ + /** + * Provenance of this entry: + * - "terminal": classified from terminal output (regex/heuristic; deprecated for hook-capable agents) + * - "native": read from the agent's own JSONL/API + * - "hook": emitted by an agent lifecycle hook (e.g. Claude Code's PermissionRequest, Stop, StopFailure) + */ + source: "terminal" | "native" | "hook"; + /** Raw terminal snippet, hook event name, or other context that caused waiting_input/blocked (for debugging) */ trigger?: string; } @@ -475,6 +480,13 @@ export interface Agent { /** Process name to look for (e.g. "claude", "codex", "aider") */ readonly processName: string; + /** + * How the initial user prompt is delivered. + * Defaults to inline, meaning the agent embeds the prompt in getLaunchCommand(). + * Use post-launch for interactive CLIs that must start first and receive input over stdin. + */ + readonly promptDelivery?: "inline" | "post-launch"; + /** Get the shell command to launch this agent */ getLaunchCommand(config: AgentLaunchConfig): string; @@ -1334,6 +1346,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 +1388,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; @@ -2079,11 +2104,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/version-compare.ts b/packages/core/src/version-compare.ts index 224678656..a6145e16c 100644 --- a/packages/core/src/version-compare.ts +++ b/packages/core/src/version-compare.ts @@ -8,6 +8,34 @@ * Spec: release-process.html §07 (Auto-update mechanics). */ +import type { UpdateChannel } from "./global-config.js"; + +export function isVersionOutdatedForChannel( + current: string, + latest: string, + channel: UpdateChannel, +): boolean { + if (channel === "nightly") { + const currentParsed = parseVersion(current); + const latestParsed = parseVersion(latest); + if ( + isNightlyPrerelease(currentParsed.prerelease) && + isNightlyPrerelease(latestParsed.prerelease) + ) { + return current !== latest; + } + } + return isVersionOutdated(current, latest); +} + +function isNightlyPrerelease(prerelease: string | undefined): boolean { + return ( + prerelease === "nightly" || + prerelease?.startsWith("nightly-") === true || + prerelease?.startsWith("nightly.") === true + ); +} + /** * Returns true if `current` is an older version than `latest`. * 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 diff --git a/packages/notifier-macos/CHANGELOG.md b/packages/notifier-macos/CHANGELOG.md new file mode 100644 index 000000000..40086a243 --- /dev/null +++ b/packages/notifier-macos/CHANGELOG.md @@ -0,0 +1,7 @@ +# @aoagents/ao-notifier-macos + +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. 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..3a68ed89a --- /dev/null +++ b/packages/notifier-macos/package.json @@ -0,0 +1,40 @@ +{ + "name": "@aoagents/ao-notifier-macos", + "version": "0.9.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-aider/CHANGELOG.md b/packages/plugins/agent-aider/CHANGELOG.md index 2d281b453..c61e362b4 100644 --- a/packages/plugins/agent-aider/CHANGELOG.md +++ b/packages/plugins/agent-aider/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-agent-aider +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Minor Changes diff --git a/packages/plugins/agent-aider/package.json b/packages/plugins/agent-aider/package.json index 0d099bab4..233654890 100644 --- a/packages/plugins/agent-aider/package.json +++ b/packages/plugins/agent-aider/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-aider", - "version": "0.8.0", + "version": "0.9.0", "description": "Agent plugin: Aider", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-claude-code/CHANGELOG.md b/packages/plugins/agent-claude-code/CHANGELOG.md index 00a33ac2e..429529639 100644 --- a/packages/plugins/agent-claude-code/CHANGELOG.md +++ b/packages/plugins/agent-claude-code/CHANGELOG.md @@ -1,5 +1,81 @@ # @aoagents/ao-plugin-agent-claude-code +## 0.9.0 + +### Minor Changes + +- 7d9b862: Replace Claude Code terminal-regex activity detection with platform-event hooks (#1941). + + Claude Code emits a lifecycle hook on every state transition that matters + (`PermissionRequest`, `StopFailure`, `Notification`, `Stop`, `PreToolUse`, + …). Until now, AO ignored all but one of them and tried to infer the + same information by regex-matching Claude's rendered terminal output — + fragile by construction. Every Claude UI tweak (footer wording, status + verb, spinner glyph) broke a heuristic; PR #1932 spent 15 commits + patching the sharpest edges. + + This release pivots: + + **`@aoagents/ao-plugin-agent-claude-code`** now installs two scripts per + workspace: + - `metadata-updater` — unchanged; PostToolUse(Bash) extracts gh/git + side-effects (PR URL, branch, merge status). + - `activity-updater` — new; registered on every hook that carries + activity information (SessionStart, UserPromptSubmit, PreToolUse, + PostToolUse, PostToolUseFailure, PostToolBatch, Notification, + PermissionRequest, Stop, StopFailure, SubagentStart, SubagentStop, + PreCompact, PostCompact). The script reads the JSON payload from + stdin, maps `hook_event_name` to an activity state, and appends a + JSONL entry to `{workspace}/.ao/activity.jsonl` with `source: "hook"`. + + Notification is filtered by `notification_type` so `auth_success` / + `elicitation_*` no longer false-fire `waiting_input` (the RFC's blanket + "Notification → waiting_input" would have regressed here). + + The terminal-regex layer (`classifyTerminalOutput`, ~80 LOC of + patterns + `agent.recordActivity`) is retired. `detectActivity` stays on + the Agent interface for other agents but is now a stable `return "idle"` + stub for Claude — the JSONL-backed cascade is the only source of truth + for active / ready / waiting_input / blocked. + + **`@aoagents/ao-core`** extends `ActivityLogEntry.source` and + `ActivitySignalSource` with a `"hook"` value so the new entries are + parseable and their provenance is visible in telemetry. No downstream + consumer needs changes — the cascade has always read whatever source + appeared in the JSONL, and the new tests assert hook-sourced entries + flow through `checkActivityLogState` / `getActivityFallbackState` + identically to terminal-sourced ones. + + Idempotent install: calling `setupWorkspaceHooks` twice keeps exactly + one entry per event and preserves user-installed hooks alongside ours. + Cross-platform: bash + Node (.cjs) variants behave identically against a + shared 52-case scenario table. + +### Patch Changes + +- a610601: Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"` → `blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903. + + Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}` → `blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider). + +- 8c71bde: 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. + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Minor Changes diff --git a/packages/plugins/agent-claude-code/package.json b/packages/plugins/agent-claude-code/package.json index ec4b77b30..573710961 100644 --- a/packages/plugins/agent-claude-code/package.json +++ b/packages/plugins/agent-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-claude-code", - "version": "0.8.0", + "version": "0.9.0", "description": "Agent plugin: Claude Code", "license": "MIT", "type": "module", 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 6199b3b19..a93f7d74d 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,11 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { existsSync, 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 { resetWarnedReaddirPaths } from "../activity-detection.js"; import { createActivitySignal, - readLastActivityEntry, type ActivityState, type Session, type RuntimeHandle, @@ -63,13 +71,20 @@ function writeJsonl( } } -function writeActivityLog(state: ActivityState, ageMs = 0): void { +function writeActivityLog( + state: ActivityState, + ageMs = 0, + source: "terminal" | "native" | "hook" = "terminal", + trigger?: string, +): void { const ts = new Date(Date.now() - ageMs).toISOString(); const aoDir = join(workspacePath, ".ao"); mkdirSync(aoDir, { recursive: true }); + const entry: Record = { ts, state, source }; + if (trigger !== undefined) entry.trigger = trigger; writeFileSync( join(aoDir, "activity.jsonl"), - JSON.stringify({ ts, state, source: "terminal" }) + "\n", + JSON.stringify(entry) + "\n", ); } @@ -115,7 +130,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 }); @@ -126,6 +146,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(() => { @@ -166,26 +189,121 @@ describe("Claude Code Activity Detection", () => { expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull(); }); + 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 }))).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 intentionally not implemented (#1941 — hooks write activity-JSONL directly)", () => { + // Lifecycle manager calls agent.recordActivity? only if defined. + // For Claude, hooks are the source of truth so this method is + // omitted — guarding here surfaces accidental re-introduction. + expect(agent.recordActivity).toBeUndefined(); }); - 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", - ); - + it("does NOT write to .ao/activity.jsonl on its own (hook-only producer)", () => { + // Without recordActivity, the plugin no longer derives anything from + // terminal output. .ao/activity.jsonl stays empty until a hook fires. expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false); }); @@ -196,8 +314,11 @@ describe("Claude Code Activity Detection", () => { 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"); + it("falls back to AO JSONL waiting_input when native session lookup is unavailable (#1941 hook entry)", async () => { + // PermissionRequest hook fires → activity-updater appends a JSONL entry + // with source: "hook". The cascade picks it up exactly like the old + // terminal-derived entry. + writeActivityLog("waiting_input", 0, "hook", "PermissionRequest (Bash)"); expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); }); @@ -206,11 +327,21 @@ describe("Claude Code Activity Detection", () => { 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"); + writeActivityLog("waiting_input", 0, "hook", "PermissionRequest"); expect((await agent.getActivityState(session))?.state).toBe("waiting_input"); }); + it("surfaces blocked from a StopFailure hook entry in AO JSONL", async () => { + // StopFailure → activity-updater appends `{state: blocked, source: hook, + // trigger: "StopFailure (rate_limit)"}`. With no Claude native JSONL + // present, the cascade must surface it through checkActivityLogState. + writeActivityLog("blocked", 0, "hook", "StopFailure (rate_limit)"); + + const result = await agent.getActivityState(makeSession()); + expect(result?.state).toBe("blocked"); + }); + 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() }); @@ -252,19 +383,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"); }); }); @@ -273,29 +484,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"); }); }); @@ -324,13 +525,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"); }); 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..af64c6cb5 --- /dev/null +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -0,0 +1,471 @@ +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 — retired (#1941) +// ============================================================================= + +/** + * Retained as a stable no-signal stub for the deprecated + * `Agent.detectActivity` method on the Claude plugin. + * + * Claude activity is now derived from platform-event hooks + * (PermissionRequest / StopFailure / Notification / Stop / PreToolUse / ...) + * which write directly to `{workspace}/.ao/activity.jsonl`. The previous + * implementation regex-matched Claude's rendered terminal output, which + * regressed every time Claude's UI footer or status-line wording changed + * (15-commit churn in #1932 motivated the rewrite). + * + * The function is preserved so the Claude agent's `detectActivity` can + * delegate to a stable export rather than inlining `() => "idle"`, and + * because the hard-deprecated `detectActivity` method on the `Agent` + * interface still has callers outside this plugin (lifecycle-manager's + * terminal-output fallback, used by agents that haven't moved to hooks). + */ +export function classifyTerminalOutput(_terminalOutput: string): ActivityState { + 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/activity-updater.integration.test.ts b/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts new file mode 100644 index 000000000..56ee653d4 --- /dev/null +++ b/packages/plugins/agent-claude-code/src/activity-updater.integration.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { isWindows } from "@aoagents/ao-core"; +import { ACTIVITY_UPDATER_SCRIPT, ACTIVITY_UPDATER_SCRIPT_NODE } from "./index.js"; + +// --------------------------------------------------------------------------- +// Integration tests for the activity-updater hook script (#1941). +// Pipes synthetic Claude Code hook JSON payloads into the real script and +// asserts the JSONL line written to {workspace}/.ao/activity.jsonl matches. +// +// Both the bash variant (Unix) and the Node variant (Windows) are exercised +// against the same scenario table to keep them in lockstep. +// --------------------------------------------------------------------------- + +let scratchDir: string; +let bashScript: string; +let nodeScript: string; + +beforeAll(() => { + scratchDir = mkdtempSync(join(tmpdir(), "ao-activity-hook-")); + bashScript = join(scratchDir, "activity-updater.sh"); + nodeScript = join(scratchDir, "activity-updater.cjs"); + writeFileSync(bashScript, ACTIVITY_UPDATER_SCRIPT, { mode: 0o755 }); + writeFileSync(nodeScript, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8"); +}); + +afterAll(() => { + rmSync(scratchDir, { recursive: true, force: true }); +}); + +interface HookInput { + hook_event_name: string; + // Common-payload fields are optional in this synthetic shape; runtime payloads always have them. + session_id?: string; + cwd?: string; + notification_type?: string; + tool_name?: string; + error_type?: string; + error_message?: string; +} + +interface HookResult { + stdout: string; + lastEntry: Record | null; + rawJsonl: string; +} + +type Variant = "bash" | "node"; + +function runHook(variant: Variant, payload: HookInput): HookResult { + const workspace = mkdtempSync(join(scratchDir, "ws-")); + const input = JSON.stringify(payload); + let stdout: string; + try { + const cmd = variant === "bash" ? `bash "${bashScript}"` : `node "${nodeScript}"`; + stdout = execSync(cmd, { + input, + env: { ...process.env, CLAUDE_PROJECT_DIR: workspace }, + encoding: "utf-8", + timeout: 5000, + }); + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string }; + stdout = e.stdout ?? ""; + } + + const logFile = join(workspace, ".ao", "activity.jsonl"); + let rawJsonl = ""; + let lastEntry: Record | null = null; + if (existsSync(logFile)) { + rawJsonl = readFileSync(logFile, "utf-8"); + const lines = rawJsonl.split("\n").filter((l) => l.trim()); + if (lines.length > 0) { + try { + lastEntry = JSON.parse(lines[lines.length - 1]!) as Record; + } catch { + lastEntry = null; + } + } + } + return { stdout, lastEntry, rawJsonl }; +} + +// Each scenario is executed against both the bash and the Node variant so +// drift between the two implementations is caught immediately. The bash +// suite is skipped on Windows — bash isn't a native shell there, so +// `execSync('bash "..."')` would throw ENOENT for every case. The Node +// variant is the Windows-supported path (and is exercised on Unix too, +// guarding against drift). Matches the `describe.skipIf` pattern used by +// `packages/core/src/__tests__/migration-storage-v2.test.ts`. +const variants: Variant[] = ["bash", "node"]; + +for (const variant of variants) { + describe.skipIf(variant === "bash" && isWindows())(`activity-updater (${variant})`, () => { + // ----------------------------------------------------------------------- + // active states — turn-in-progress markers + // ----------------------------------------------------------------------- + it.each([ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PreCompact", + "PostCompact", + "SubagentStart", + "PostToolBatch", + ])("writes active for %s", (event) => { + const { lastEntry } = runHook(variant, { hook_event_name: event }); + expect(lastEntry).not.toBeNull(); + expect(lastEntry!.state).toBe("active"); + expect(lastEntry!.source).toBe("hook"); + expect(lastEntry).not.toHaveProperty("trigger"); + }); + + // ----------------------------------------------------------------------- + // ready states — turn boundaries + // ----------------------------------------------------------------------- + it.each(["SessionStart", "Stop", "SubagentStop"])("writes ready for %s", (event) => { + const { lastEntry } = runHook(variant, { hook_event_name: event }); + expect(lastEntry!.state).toBe("ready"); + expect(lastEntry!.source).toBe("hook"); + }); + + // ----------------------------------------------------------------------- + // waiting_input — PermissionRequest is the authoritative signal + // ----------------------------------------------------------------------- + it("writes waiting_input for PermissionRequest with tool_name in trigger", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "PermissionRequest", + tool_name: "Bash", + }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.source).toBe("hook"); + expect(lastEntry!.trigger).toBe("PermissionRequest (Bash)"); + }); + + it("writes waiting_input for PermissionRequest without tool_name", () => { + const { lastEntry } = runHook(variant, { hook_event_name: "PermissionRequest" }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.trigger).toBe("PermissionRequest"); + }); + + // ----------------------------------------------------------------------- + // Notification — MUST filter by notification_type so auth_success / + // elicitation_* don't false-fire waiting_input. + // ----------------------------------------------------------------------- + it("writes waiting_input for Notification(permission_prompt)", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "permission_prompt", + }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.trigger).toBe("Notification (permission_prompt)"); + }); + + it("writes waiting_input for Notification(idle_prompt)", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "idle_prompt", + }); + expect(lastEntry!.state).toBe("waiting_input"); + expect(lastEntry!.trigger).toBe("Notification (idle_prompt)"); + }); + + it("skips Notification(auth_success) — not a stuck-on-the-user state", () => { + const { lastEntry, stdout } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "auth_success", + }); + expect(lastEntry).toBeNull(); + expect(stdout.trim()).toBe("{}"); + }); + + it("skips Notification(elicitation_response)", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "Notification", + notification_type: "elicitation_response", + }); + expect(lastEntry).toBeNull(); + }); + + // ----------------------------------------------------------------------- + // blocked — StopFailure is the authoritative API-error signal + // ----------------------------------------------------------------------- + it("writes blocked for StopFailure with error_type in trigger", () => { + const { lastEntry } = runHook(variant, { + hook_event_name: "StopFailure", + error_type: "rate_limit", + error_message: "Rate limited", + }); + expect(lastEntry!.state).toBe("blocked"); + expect(lastEntry!.source).toBe("hook"); + expect(lastEntry!.trigger).toBe("StopFailure (rate_limit)"); + }); + + it("writes blocked for StopFailure without error_type", () => { + const { lastEntry } = runHook(variant, { hook_event_name: "StopFailure" }); + expect(lastEntry!.state).toBe("blocked"); + expect(lastEntry!.trigger).toBe("StopFailure"); + }); + + // ----------------------------------------------------------------------- + // No-ops — unknown event names + ignored events + // ----------------------------------------------------------------------- + it.each(["SessionEnd", "TaskCreated", "FileChanged", "UnknownFutureEvent"])( + "ignores unhandled event %s", + (event) => { + const { lastEntry, stdout } = runHook(variant, { hook_event_name: event }); + expect(lastEntry).toBeNull(); + expect(stdout.trim()).toBe("{}"); + }, + ); + + it("returns {} stdout so Claude doesn't surface a hook decision", () => { + const { stdout } = runHook(variant, { hook_event_name: "Stop" }); + expect(stdout.trim()).toBe("{}"); + }); + + it("creates .ao/ directory on first write", () => { + const { rawJsonl } = runHook(variant, { hook_event_name: "Stop" }); + expect(rawJsonl.length).toBeGreaterThan(0); + }); + + it("emits a parseable JSON line with a valid ISO timestamp", () => { + const { lastEntry } = runHook(variant, { hook_event_name: "Stop" }); + expect(lastEntry).not.toBeNull(); + const ts = lastEntry!.ts as string; + expect(typeof ts).toBe("string"); + // ISO 8601 with millisecond precision and Z suffix + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/); + }); + + it("escapes control chars in trigger so the JSONL line stays parseable", () => { + // Bounded by Claude's enums today (`error_type`, `tool_name` never contain + // \n/\r/\t), but if a future hook payload field flows into `trigger` + // unescaped, a literal newline would split one entry into two and break + // the JSONL parser. This locks in JSON-style escaping across both + // bash and Node variants. + const { lastEntry, rawJsonl } = runHook(variant, { + hook_event_name: "StopFailure", + // Smuggle a multi-line value through error_type — covers \n/\t/\r/\\/". + error_type: 'multi\nline\twith\rmixed\\\\and"quotes', + }); + // Exactly one JSONL line (control chars escaped, not literal) + expect(rawJsonl.split("\n").filter((l) => l.trim())).toHaveLength(1); + // Parsed entry round-trips the original string + expect(lastEntry!.state).toBe("blocked"); + expect(lastEntry!.trigger).toBe( + 'StopFailure (multi\nline\twith\rmixed\\\\and"quotes)', + ); + }); + }); +} diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index e664509a0..8b65084b3 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -78,6 +78,8 @@ import { toClaudeProjectPath, METADATA_UPDATER_SCRIPT, METADATA_UPDATER_SCRIPT_NODE, + ACTIVITY_UPDATER_SCRIPT, + ACTIVITY_UPDATER_SCRIPT_NODE, } from "./index.js"; // --------------------------------------------------------------------------- @@ -375,6 +377,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 +482,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 @@ -470,7 +495,18 @@ describe("isProcessRunning", () => { // ========================================================================= // detectActivity — terminal output classification // ========================================================================= -describe("detectActivity", () => { +describe("detectActivity (retired — see #1941)", () => { + // Claude activity is derived from platform-event hooks (PermissionRequest, + // StopFailure, Notification, Stop, ...) which write directly to + // .ao/activity.jsonl with source: "hook". The terminal-regex layer was + // structurally fragile (every Claude UI tweak regressed it; #1932 spent + // 15 commits patching its sharpest edges) and has been retired. + // + // The `detectActivity` method is kept on the Agent interface for other + // plugins (Aider, OpenCode, Codex fallback) but is a stable no-signal + // stub for Claude — returns "idle" for every input so the lifecycle + // manager's terminal-output path stays neutral and the JSONL-backed + // cascade is the only source of truth for active/ready/waiting_input/blocked. const agent = create(); it("returns idle for empty terminal output", () => { @@ -481,70 +517,20 @@ describe("detectActivity", () => { expect(agent.detectActivity(" \n \n ")).toBe("idle"); }); - it("returns active when 'esc to interrupt' is visible", () => { - expect(agent.detectActivity("Working... esc to interrupt\n")).toBe("active"); - }); - - it("returns active when Thinking indicator is visible", () => { - expect(agent.detectActivity("Thinking...\n")).toBe("active"); - }); - - it("returns active when Reading indicator is visible", () => { - expect(agent.detectActivity("Reading file src/index.ts\n")).toBe("active"); - }); - - it("returns active when Writing indicator is visible", () => { - expect(agent.detectActivity("Writing to src/main.ts\n")).toBe("active"); - }); - - it("returns active when Searching indicator is visible", () => { - expect(agent.detectActivity("Searching codebase...\n")).toBe("active"); - }); - - it("returns waiting_input for permission prompt (Y/N)", () => { - expect(agent.detectActivity("Do you want to proceed? (Y)es / (N)o\n")).toBe("waiting_input"); - }); - - it("returns waiting_input for 'Do you want to proceed?' prompt", () => { - expect(agent.detectActivity("Do you want to proceed?\n")).toBe("waiting_input"); - }); - - it("returns waiting_input for bypass permissions prompt", () => { - expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe( - "waiting_input", - ); - }); - - it("returns active when queued message indicator is visible", () => { - expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active"); - }); - - it("returns idle when shell prompt is visible", () => { - expect(agent.detectActivity("some output\n> ")).toBe("idle"); - expect(agent.detectActivity("some output\n$ ")).toBe("idle"); - }); - - it("returns idle when prompt follows historical activity indicators", () => { - // Key regression test: historical "Reading file..." output in the buffer - // should NOT override an idle prompt on the last line. - expect(agent.detectActivity("Reading file src/index.ts\nWriting to out.ts\n❯ ")).toBe("idle"); - expect(agent.detectActivity("Thinking...\nSearching codebase...\n$ ")).toBe("idle"); - }); - - it("returns waiting_input when permission prompt follows historical activity", () => { - // Permission prompt at the bottom should NOT be overridden by historical - // "Reading"/"Thinking" output higher in the buffer. - expect( - agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n"), - ).toBe("waiting_input"); - expect(agent.detectActivity("Searching codebase...\n(Y)es / (N)o\n")).toBe("waiting_input"); - expect( - agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n"), - ).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.each([ + "Working... esc to interrupt\n", + "Thinking...\n", + "Reading file src/index.ts\n", + "Writing to src/main.ts\n", + "Searching codebase...\n", + "Do you want to proceed? (Y)es / (N)o\n", + "bypass all future permissions for this session\n", + " ⎿ Unable to connect to API (ConnectionRefused)\n", + " Retrying in 19s · attempt 7/10\n", + "✻ Fluttering… (6m 49s · ↓ 26.9k tokens)\n", + "some random terminal output\n", + ])("returns idle for ALL non-empty input (no terminal-regex active/waiting_input/blocked): %s", (input) => { + expect(agent.detectActivity(input)).toBe("idle"); }); }); @@ -1019,6 +1005,269 @@ describe("hook setup — relative path (symlink-safe)", () => { }); }); +// ========================================================================= +// setupWorkspaceHooks — activity-updater registration (#1941) +// ========================================================================= +describe("setupWorkspaceHooks — activity-updater (#1941)", () => { + const agent = create(); + + function getParsedSettings(): Record { + const settingsWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + expect(settingsWrite).toBeDefined(); + return JSON.parse(settingsWrite![1] as string) as Record; + } + + /** Activity-updater command paths (unix vs win32) */ + const ACTIVITY_CMD_UNIX = ".claude/activity-updater.sh"; + const ACTIVITY_CMD_WIN = "node .claude/activity-updater.cjs"; + + /** + * Every Claude Code hook event the script knows how to translate into an + * activity state. The dashboard / lifecycle reducer relies on these firing + * so platform events replace terminal-output regex. + */ + const ACTIVITY_EVENTS = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Notification", + "PermissionRequest", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + ] as const; + + it("writes the activity-updater script to .claude/", async () => { + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const scriptWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"), + ); + expect(scriptWrite).toBeDefined(); + expect(scriptWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT); + }); + + it("makes the activity-updater script executable on unix (chmod 0o755)", async () => { + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const chmodCall = mockChmod.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"), + ); + expect(chmodCall).toBeDefined(); + expect(chmodCall![1]).toBe(0o755); + }); + + it.each(ACTIVITY_EVENTS)( + "registers the activity-updater hook on %s", + async (event) => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const hookGroup = (settings.hooks as Record)[event] as Array<{ + matcher: string; + hooks: Array<{ command: string; timeout?: number }>; + }>; + expect(hookGroup).toBeDefined(); + const activity = hookGroup.flatMap((g) => g.hooks).find((h) => h.command === ACTIVITY_CMD_UNIX); + expect(activity).toBeDefined(); + // The script does a single JSON parse + append — short timeout keeps a + // stuck hook from slowing the turn down. + expect(activity!.timeout).toBe(2000); + }, + ); + + it("registers activity-updater PostToolUse alongside metadata-updater", async () => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const postToolUse = (settings.hooks as Record)["PostToolUse"] as Array<{ + matcher: string; + hooks: Array<{ command: string }>; + }>; + expect(postToolUse.length).toBeGreaterThanOrEqual(2); + + const metadataEntry = postToolUse.find((g) => + g.hooks.some((h) => h.command.includes("metadata-updater")), + ); + const activityEntry = postToolUse.find((g) => + g.hooks.some((h) => h.command.includes("activity-updater")), + ); + + expect(metadataEntry).toBeDefined(); + expect(metadataEntry!.matcher).toBe("Bash"); // unchanged from before #1941 + expect(activityEntry).toBeDefined(); + expect(activityEntry!.matcher).toBe(""); // fires on every PostToolUse, not just Bash + }); + + it("is idempotent — calling twice keeps exactly one activity-updater entry per event", async () => { + mockWriteFile.mockClear(); + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + const firstSettings = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"), + ); + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(firstSettings![1] as string); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + for (const event of ACTIVITY_EVENTS) { + const hookGroup = (settings.hooks as Record)[event] as Array<{ + hooks: Array<{ command: string }>; + }>; + const activityHooks = hookGroup.flatMap((g) => g.hooks).filter( + (h) => h.command === ACTIVITY_CMD_UNIX, + ); + expect(activityHooks).toHaveLength(1); + } + }); + + it("preserves a user-installed Stop hook when adding our activity-updater", async () => { + const existingSettings = { + hooks: { + Stop: [ + { + matcher: "", + hooks: [{ type: "command", command: "echo user-hook", timeout: 1000 }], + }, + ], + }, + }; + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings)); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ + hooks: Array<{ command: string }>; + }>; + const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command); + expect(commands).toContain("echo user-hook"); // user hook preserved + expect(commands).toContain(ACTIVITY_CMD_UNIX); // our hook added + }); + + it("tolerates malformed hooks. (object instead of array)", async () => { + // A user could hand-edit settings.json or an older plugin could have + // written a non-array shape there. We must not crash — start fresh. + const malformed = { + hooks: { + // Object where an array is expected + Stop: { matcher: "", command: "broken" }, + }, + }; + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(JSON.stringify(malformed)); + mockWriteFile.mockClear(); + + await expect( + agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig), + ).resolves.not.toThrow(); + + const settings = getParsedSettings(); + const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ + hooks: Array<{ command: string }>; + }>; + expect(Array.isArray(stopGroup)).toBe(true); + const commands = stopGroup.flatMap((g) => g.hooks).map((h) => h.command); + expect(commands).toContain(ACTIVITY_CMD_UNIX); + }); + + it("preserves matcher of an entry where user co-located their own def alongside ours", async () => { + // User has added their own hook def into the SAME { matcher, hooks: [...] } + // object that contains our activity-updater. If we naively reset + // entry.matcher to ours (""), the user's def starts firing on every + // PreToolUse event instead of only "Edit|Write". + const existingSettings = { + hooks: { + PreToolUse: [ + { + matcher: "Edit|Write", + hooks: [ + { type: "command", command: ".claude/activity-updater.sh", timeout: 2000 }, + { type: "command", command: "echo user-edits-only", timeout: 1000 }, + ], + }, + ], + }, + }; + mockExistsSync.mockReturnValueOnce(true); + mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings)); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig); + + const settings = getParsedSettings(); + const pre = (settings.hooks as Record)["PreToolUse"] as Array<{ + matcher: string; + hooks: Array<{ command: string }>; + }>; + const sharedEntry = pre.find((g) => + g.hooks.some((h) => h.command === "echo user-edits-only"), + ); + expect(sharedEntry).toBeDefined(); + // Matcher must NOT be overwritten — user's hook keeps firing on "Edit|Write" + expect(sharedEntry!.matcher).toBe("Edit|Write"); + // Both defs still present + expect(sharedEntry!.hooks.map((h) => h.command)).toEqual([ + ACTIVITY_CMD_UNIX, + "echo user-edits-only", + ]); + }); + + it("on Windows writes activity-updater.cjs (not .sh) and uses node invocation", async () => { + mockIsWindows.mockReturnValue(true); + mockWriteFile.mockClear(); + + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); + + const cjsWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"), + ); + expect(cjsWrite).toBeDefined(); + expect(cjsWrite![1]).toBe(ACTIVITY_UPDATER_SCRIPT_NODE); + + const shWrite = mockWriteFile.mock.calls.find( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.sh"), + ); + expect(shWrite).toBeUndefined(); + + const settings = getParsedSettings(); + const stopGroup = (settings.hooks as Record)["Stop"] as Array<{ + hooks: Array<{ command: string }>; + }>; + expect(stopGroup.flatMap((g) => g.hooks).some((h) => h.command === ACTIVITY_CMD_WIN)).toBe(true); + + mockIsWindows.mockReturnValue(false); + }); + + it("does not chmod on Windows (Windows uses extension for executability)", async () => { + mockIsWindows.mockReturnValue(true); + mockChmod.mockClear(); + + await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig); + + const chmodCalls = mockChmod.mock.calls.filter( + ([path]: unknown[]) => typeof path === "string" && path.endsWith("activity-updater.cjs"), + ); + expect(chmodCalls).toHaveLength(0); + + mockIsWindows.mockReturnValue(false); + }); +}); + // ========================================================================= // setupWorkspaceHooks on win32 — Node.js hook script // ========================================================================= diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 7235b05b4..934fe3be2 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -1,15 +1,7 @@ import { shellEscape, - readLastJsonlEntry, normalizeAgentPermissionMode, isWindows, - PROCESS_PROBE_INDETERMINATE, - DEFAULT_READY_THRESHOLD_MS, - DEFAULT_ACTIVE_WINDOW_MS, - readLastActivityEntry, - checkActivityLogState, - getActivityFallbackState, - recordTerminalActivity, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -23,14 +15,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 @@ -385,6 +384,237 @@ process.stdout.write("{}\\n"); process.exit(0); `; +// ============================================================================= +// Activity Updater Hook Script +// ============================================================================= + +/** + * Bash hook script that translates Claude Code lifecycle hooks into AO activity + * JSONL entries. Registered on every event whose firing carries activity + * information (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, + * PermissionRequest, Notification, Stop, SubagentStop, StopFailure, PreCompact, + * PostCompact, SubagentStart, PostToolBatch). + * + * Reads the JSON payload from stdin, parses `hook_event_name`, maps it to an + * activity state, and appends a single JSONL entry to + * `$CLAUDE_PROJECT_DIR/.ao/activity.jsonl` with `source: "hook"`. + * + * Notification is filtered by `notification_type` — only `permission_prompt` + * and `idle_prompt` map to `waiting_input`; `auth_success`/`elicitation_*` etc. + * are skipped because they don't represent a stuck-on-the-user transition. + * + * The script always exits 0 (never blocks Claude). Unknown events exit + * silently. Exported for integration testing. + */ +export const ACTIVITY_UPDATER_SCRIPT = `#!/usr/bin/env bash +# Activity Updater Hook for Agent Orchestrator +# +# Records Claude Code lifecycle events to {workspace}/.ao/activity.jsonl so +# the dashboard / lifecycle reducer derives activity state from authoritative +# platform events instead of regex over rendered terminal output. (#1941) + +set -uo pipefail + +input=$(cat) + +if command -v jq &>/dev/null; then + event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty') + notif_type=$(printf '%s' "$input" | jq -r '.notification_type // empty') + tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty') + error_type=$(printf '%s' "$input" | jq -r '.error_type // empty') +else + event=$(printf '%s' "$input" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) + notif_type=$(printf '%s' "$input" | grep -o '"notification_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) + tool_name=$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) + error_type=$(printf '%s' "$input" | grep -o '"error_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4) +fi + +state="" +trigger="" +case "$event" in + SessionStart|Stop|SubagentStop) + state="ready" + trigger="$event" + ;; + UserPromptSubmit|PreToolUse|PostToolUse|PostToolUseFailure|PreCompact|PostCompact|SubagentStart|PostToolBatch) + state="active" + trigger="$event" + ;; + PermissionRequest) + state="waiting_input" + if [[ -n "$tool_name" ]]; then + trigger="PermissionRequest ($tool_name)" + else + trigger="PermissionRequest" + fi + ;; + Notification) + if [[ "$notif_type" == "permission_prompt" || "$notif_type" == "idle_prompt" ]]; then + state="waiting_input" + trigger="Notification ($notif_type)" + else + # auth_success / elicitation_* / unrecognized — not an activity transition + echo '{}' + exit 0 + fi + ;; + StopFailure) + state="blocked" + if [[ -n "$error_type" ]]; then + trigger="StopFailure ($error_type)" + else + trigger="StopFailure" + fi + ;; + *) + echo '{}' + exit 0 + ;; +esac + +workspace="\${CLAUDE_PROJECT_DIR:-$(pwd)}" +log_dir="$workspace/.ao" +log_file="$log_dir/activity.jsonl" + +mkdir -p "$log_dir" 2>/dev/null || { echo '{}'; exit 0; } + +# Node is a hard runtime dep of Claude Code, so node -p is always available +# and gives millisecond-precision ISO timestamps matching the rest of the +# activity-JSONL log. Fall back to seconds-precision date for the unlikely +# case where node is unavailable (still valid ISO 8601). +ts=$(node -p 'new Date().toISOString()' 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Escape JSON-special characters in the trigger value. Triggers are bounded +# today to event/tool/error names (no control chars in practice) but escape +# defensively — \\ and " for content, plus the five common control chars +# (\\n \\r \\t \\b \\f) so the JSONL line stays parseable for any future +# trigger source. Matches what Node's JSON.stringify produces in the .cjs +# variant so both implementations stay in lockstep. +escape_json() { + local s="$1" + s="\${s//\\\\/\\\\\\\\}" + s="\${s//\\"/\\\\\\"}" + s="\${s//$'\\n'/\\\\n}" + s="\${s//$'\\r'/\\\\r}" + s="\${s//$'\\t'/\\\\t}" + s="\${s//$'\\b'/\\\\b}" + s="\${s//$'\\f'/\\\\f}" + printf '%s' "$s" +} + +if [[ "$state" == "waiting_input" || "$state" == "blocked" ]]; then + esc_trigger=$(escape_json "$trigger") + printf '{"ts":"%s","state":"%s","source":"hook","trigger":"%s"}\\n' "$ts" "$state" "$esc_trigger" >> "$log_file" +else + printf '{"ts":"%s","state":"%s","source":"hook"}\\n' "$ts" "$state" >> "$log_file" +fi + +echo '{}' +exit 0 +`; + +/** + * Node.js equivalent of ACTIVITY_UPDATER_SCRIPT for Windows. No bash, no jq, + * no shebang interpretation; relies only on Node built-ins. Exported for + * testing. + */ +export const ACTIVITY_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node +// Activity Updater Hook for Agent Orchestrator (Node.js — Windows). See +// ACTIVITY_UPDATER_SCRIPT for the canonical bash version. (#1941) + +const { appendFileSync, mkdirSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +let inputRaw = ""; +try { + inputRaw = readFileSync(0, "utf-8"); +} catch { + process.stdout.write("{}\\n"); + process.exit(0); +} + +let payload; +try { + payload = JSON.parse(inputRaw || "{}"); +} catch { + process.stdout.write("{}\\n"); + process.exit(0); +} + +const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : ""; +const notifType = typeof payload.notification_type === "string" ? payload.notification_type : ""; +const toolName = typeof payload.tool_name === "string" ? payload.tool_name : ""; +const errorType = typeof payload.error_type === "string" ? payload.error_type : ""; + +let state = ""; +let trigger = ""; +switch (event) { + case "SessionStart": + case "Stop": + case "SubagentStop": + state = "ready"; + trigger = event; + break; + case "UserPromptSubmit": + case "PreToolUse": + case "PostToolUse": + case "PostToolUseFailure": + case "PreCompact": + case "PostCompact": + case "SubagentStart": + case "PostToolBatch": + state = "active"; + trigger = event; + break; + case "PermissionRequest": + state = "waiting_input"; + trigger = toolName ? \`PermissionRequest (\${toolName})\` : "PermissionRequest"; + break; + case "Notification": + if (notifType === "permission_prompt" || notifType === "idle_prompt") { + state = "waiting_input"; + trigger = \`Notification (\${notifType})\`; + } else { + process.stdout.write("{}\\n"); + process.exit(0); + } + break; + case "StopFailure": + state = "blocked"; + trigger = errorType ? \`StopFailure (\${errorType})\` : "StopFailure"; + break; + default: + process.stdout.write("{}\\n"); + process.exit(0); +} + +const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd(); +const logDir = join(workspace, ".ao"); +const logFile = join(logDir, "activity.jsonl"); + +try { + mkdirSync(logDir, { recursive: true }); +} catch { + process.stdout.write("{}\\n"); + process.exit(0); +} + +const ts = new Date().toISOString(); +const entry = + state === "waiting_input" || state === "blocked" + ? { ts, state, source: "hook", trigger } + : { ts, state, source: "hook" }; + +try { + appendFileSync(logFile, JSON.stringify(entry) + "\\n", "utf-8"); +} catch { + // Best-effort — never block Claude on log append failure +} + +process.stdout.write("{}\\n"); +process.exit(0); +`; + // ============================================================================= // Plugin Manifest // ============================================================================= @@ -401,56 +631,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; @@ -612,203 +792,193 @@ 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 // ============================================================================= /** - * Shared helper to setup PostToolUse hooks in a workspace. - * Writes metadata-updater.sh script and updates settings.json. + * Single hook registration: which event, which variant (matcher), which + * command to invoke, and a substring used to find-and-update an existing + * entry so repeated setup calls are idempotent. + */ +interface HookRegistration { + event: string; + matcher: string; + command: string; + timeout: number; + /** Substring(s) of `command` that identify a pre-existing entry to update. */ + identifiers: ReadonlyArray; +} + +/** + * Set the registration's hook in the `event`'s hook array, updating any + * existing entry whose command contains one of `identifiers` (idempotent). * - * @param workspacePath - Path to the workspace directory - * @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR) + * Tolerates malformed pre-existing settings: if `hooks[event]` is not an + * array (object, string, missing) we start a fresh array rather than + * throwing on `.push`. + * + * Only refreshes the entry-level `matcher` when the entry contains a single + * hook def (ours). When a user has co-located their own hook def in the + * same `{ matcher, hooks: [...] }` object, we leave their matcher alone and + * only update our def's `command`/`timeout` so their hook keeps firing on + * the matchers they chose. + */ +function upsertHookEntry( + hooks: Record, + reg: HookRegistration, +): void { + const existing = hooks[reg.event]; + const entries: Array = Array.isArray(existing) ? existing : []; + + let foundEntryIdx = -1; + let foundDefIdx = -1; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const hooksList = (entry as Record)["hooks"]; + if (!Array.isArray(hooksList)) continue; + for (let j = 0; j < hooksList.length; j++) { + const def = hooksList[j]; + if (typeof def !== "object" || def === null || Array.isArray(def)) continue; + const cmd = (def as Record)["command"]; + if (typeof cmd === "string" && reg.identifiers.some((id) => cmd.includes(id))) { + foundEntryIdx = i; + foundDefIdx = j; + break; + } + } + if (foundEntryIdx >= 0) break; + } + + if (foundEntryIdx === -1) { + entries.push({ + matcher: reg.matcher, + hooks: [{ type: "command", command: reg.command, timeout: reg.timeout }], + }); + } else { + const entry = entries[foundEntryIdx] as Record; + const hooksList = entry["hooks"] as Array>; + hooksList[foundDefIdx]!["command"] = reg.command; + hooksList[foundDefIdx]!["timeout"] = reg.timeout; + // Only refresh the matcher when the entry is clearly owned by AO + // (single hook def == ours). With multiple defs the entry is shared + // with a user hook; changing the matcher would change when their hook + // fires. + if (hooksList.length === 1) { + entry["matcher"] = reg.matcher; + } + } + + hooks[reg.event] = entries; +} + +/** + * Build the list of hooks to register for this workspace. Two scripts are + * installed: + * - metadata-updater: PostToolUse(Bash) only — extracts gh/git side-effects. + * - activity-updater: every event that carries activity information, so + * dashboard / lifecycle reducer state derives from platform events + * instead of regex over rendered terminal output (#1941). + * + * Activity events use matcher "" — match every variant. PermissionRequest's + * tool-name and Notification's notification_type are filtered inside the + * script itself so the registered set stays small. + */ +function buildHookRegistrations( + metadataCommand: string, + activityCommand: string, +): HookRegistration[] { + const METADATA_IDS = [ + "metadata-updater.sh", + "metadata-updater.cjs", + "metadata-updater.js", + ] as const; + const ACTIVITY_IDS = ["activity-updater.sh", "activity-updater.cjs"] as const; + + const regs: HookRegistration[] = [ + { + event: "PostToolUse", + matcher: "Bash", + command: metadataCommand, + timeout: 5000, + identifiers: METADATA_IDS, + }, + ]; + + // Activity-updater events. Every event that the activity-updater script + // knows how to map (see ACTIVITY_UPDATER_SCRIPT) must be registered here; + // unregistered events fire no hook, so unrecognized hooks waste no time. + const activityEvents = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Notification", + "PermissionRequest", + "Stop", + "StopFailure", + "SubagentStart", + "SubagentStop", + "PreCompact", + "PostCompact", + ]; + for (const event of activityEvents) { + regs.push({ + event, + matcher: "", + command: activityCommand, + // Hook execution is best-effort and the activity-updater is intentionally + // O(few ms): JSON parse, one append, exit. A short timeout keeps a stuck + // hook from slowing a turn down. + timeout: 2000, + identifiers: ACTIVITY_IDS, + }); + } + + return regs; +} + +/** + * Install Claude Code workspace hooks. Writes both helper scripts + * (metadata-updater + activity-updater) and merges hook registrations into + * `.claude/settings.json` — preserving any user-installed hooks, updating our + * own in place on repeated calls. */ async function setupHookInWorkspace(workspacePath: string): Promise { const claudeDir = join(workspacePath, ".claude"); const settingsPath = join(claudeDir, "settings.json"); - // Create .claude directory if it doesn't exist try { await mkdir(claudeDir, { recursive: true }); } catch { - // Directory might already exist + // Directory may already exist; ignore } - // On Windows: write a Node.js hook script, skip chmod (not needed). - // On Unix: write the bash hook script and make it executable. - let hookCommand: string; + let metadataCommand: string; + let activityCommand: string; if (isWindows()) { - const hookScriptPath = join(claudeDir, "metadata-updater.cjs"); - await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8"); - // No chmod — Windows uses file extension for executability - // Use `node` to invoke the script (Windows won't run .js via shebang) - // Use .cjs extension to force CJS mode regardless of workspace package.json "type" field - hookCommand = "node .claude/metadata-updater.cjs"; + const metadataPath = join(claudeDir, "metadata-updater.cjs"); + const activityPath = join(claudeDir, "activity-updater.cjs"); + await writeFile(metadataPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8"); + await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8"); + // .cjs forces CJS regardless of workspace package.json "type"; node + // invocation is required on Windows because shebangs aren't honoured. + metadataCommand = "node .claude/metadata-updater.cjs"; + activityCommand = "node .claude/activity-updater.cjs"; } else { - const hookScriptPath = join(claudeDir, "metadata-updater.sh"); - await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8"); - await chmod(hookScriptPath, 0o755); // Make executable - hookCommand = ".claude/metadata-updater.sh"; + const metadataPath = join(claudeDir, "metadata-updater.sh"); + const activityPath = join(claudeDir, "activity-updater.sh"); + await writeFile(metadataPath, METADATA_UPDATER_SCRIPT, "utf-8"); + await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT, "utf-8"); + await chmod(metadataPath, 0o755); + await chmod(activityPath, 0o755); + metadataCommand = ".claude/metadata-updater.sh"; + activityCommand = ".claude/activity-updater.sh"; } - // Read existing settings if present let existingSettings: Record = {}; if (existsSync(settingsPath)) { try { @@ -819,61 +989,12 @@ async function setupHookInWorkspace(workspacePath: string): Promise { } } - // Merge hooks configuration const hooks = (existingSettings["hooks"] as Record) ?? {}; - const postToolUse = (hooks["PostToolUse"] as Array) ?? []; - - // Check if our hook is already configured - let hookIndex = -1; - let hookDefIndex = -1; - for (let i = 0; i < postToolUse.length; i++) { - const hook = postToolUse[i]; - if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue; - const h = hook as Record; - const hooksList = h["hooks"]; - if (!Array.isArray(hooksList)) continue; - for (let j = 0; j < hooksList.length; j++) { - const hDef = hooksList[j]; - if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue; - const def = hDef as Record; - if ( - typeof def["command"] === "string" && - (def["command"].includes("metadata-updater.sh") || - def["command"].includes("metadata-updater.js") || - def["command"].includes("metadata-updater.cjs")) - ) { - hookIndex = i; - hookDefIndex = j; - break; - } - } - if (hookIndex >= 0) break; + for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) { + upsertHookEntry(hooks, reg); } - - // Add or update our hook - if (hookIndex === -1) { - // No metadata hook exists, add it - postToolUse.push({ - matcher: "Bash", - hooks: [ - { - type: "command", - command: hookCommand, - timeout: 5000, - }, - ], - }); - } else { - // Hook exists, update the command - const hook = postToolUse[hookIndex] as Record; - const hooksList = hook["hooks"] as Array>; - hooksList[hookDefIndex]["command"] = hookCommand; - } - - hooks["PostToolUse"] = postToolUse; existingSettings["hooks"] = hooks; - // Write updated settings await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8"); } @@ -948,113 +1069,47 @@ function createClaudeCodeAgent(): Agent { }, detectActivity(terminalOutput: string): ActivityState { + // #1941: Claude activity is derived from platform-event hooks + // (PermissionRequest / StopFailure / Notification / Stop / ...) which + // write directly to {workspace}/.ao/activity.jsonl. The terminal-regex + // layer was structurally fragile (every UI tweak in Claude regressed + // it; see the 15-commit churn in #1932) so it has been retired in + // favour of those authoritative events. + // + // detectActivity is kept on the Agent interface for other plugins + // (Aider, OpenCode, Codex fallback) that still rely on terminal output. + // For Claude, classifyTerminalOutput is a stable "idle" stub — the + // lifecycle manager only consults this method when getActivityState + // returned null (no Claude process / no JSONL / no hook entry yet), + // and in that no-signal case "idle" is the correct conservative + // answer (we don't write it back to JSONL — recordActivity is also + // intentionally omitted for Claude). return classifyTerminalOutput(terminalOutput); }, - async recordActivity(session: Session, terminalOutput: string): Promise { - if (!session.workspacePath) return; - await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => - this.detectActivity(output), - ); - }, + // recordActivity is intentionally NOT implemented for the Claude agent + // (#1941). Hooks write activity entries directly via the activity-updater + // script, so polling-driven terminal-output classification would only add + // stale duplicates to .ao/activity.jsonl. 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); - 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 { - 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 }; - } - } - } - - // 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; + 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 @@ -1084,7 +1139,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/agent-codex/CHANGELOG.md b/packages/plugins/agent-codex/CHANGELOG.md index 0149854f8..bcc3eb780 100644 --- a/packages/plugins/agent-codex/CHANGELOG.md +++ b/packages/plugins/agent-codex/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-agent-codex +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Minor Changes diff --git a/packages/plugins/agent-codex/package.json b/packages/plugins/agent-codex/package.json index 66baf3267..584821386 100644 --- a/packages/plugins/agent-codex/package.json +++ b/packages/plugins/agent-codex/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-codex", - "version": "0.8.0", + "version": "0.9.0", "description": "Agent plugin: OpenAI Codex CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 880b20a03..5499873a1 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type * as Readline from "node:readline"; import { createActivitySignal, type Session, @@ -21,6 +22,7 @@ const { mockLstat, mockOpen, mockCreateReadStream, + mockCreateInterface, mockHomedir, mockReadLastJsonlEntry, mockIsWindows, @@ -35,6 +37,7 @@ const { mockLstat: vi.fn(), mockOpen: vi.fn(), mockCreateReadStream: vi.fn(), + mockCreateInterface: vi.fn(), mockHomedir: vi.fn(() => "/mock/home"), mockReadLastJsonlEntry: vi.fn(), mockIsWindows: vi.fn(() => false), @@ -67,6 +70,17 @@ vi.mock("node:fs", () => ({ createReadStream: mockCreateReadStream, })); +vi.mock("node:readline", async (importOriginal) => { + const actual = await importOriginal(); + mockCreateInterface.mockImplementation((...args: Parameters) => + actual.createInterface(...args), + ); + return { + ...actual, + createInterface: mockCreateInterface, + }; +}); + vi.mock("node:os", () => ({ homedir: mockHomedir, })); @@ -677,6 +691,57 @@ describe("getActivityState", () => { expect(await agent.getActivityState(session)).toBeNull(); }); + it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => { + mockTmuxWithProcess("codex"); + mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl"]); + const modifiedAt = new Date(); + mockReadLastJsonlEntry.mockResolvedValue({ + lastType: "assistant_message", + modifiedAt, + }); + + const session = makeSession({ + runtimeHandle: makeTmuxHandle(), + workspacePath: "/workspace/test", + metadata: { codexThreadId: "thread-fast-activity" }, + }); + const result = await agent.getActivityState(session); + + expect(result?.state).toBe("ready"); + expect(mockReadLastJsonlEntry).toHaveBeenCalledWith( + pathJoin( + "/mock/home", + ".codex", + "sessions", + "rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl", + ), + ); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("falls back to cwd-prefix scanning when codexThreadId lookup misses", async () => { + mockTmuxWithProcess("codex"); + const content = '{"type":"session_meta","cwd":"/workspace/test"}\n'; + mockReaddir.mockResolvedValue(["rollout-other-thread.jsonl"]); + setupMockOpen(content); + mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() }); + mockReadLastJsonlEntry.mockResolvedValue({ + lastType: "assistant_message", + modifiedAt: new Date(), + }); + + const session = makeSession({ + runtimeHandle: makeTmuxHandle(), + workspacePath: "/workspace/test", + metadata: { codexThreadId: "missing-thread" }, + }); + const result = await agent.getActivityState(session); + + expect(result?.state).toBe("ready"); + expect(mockReaddir).toHaveBeenCalledTimes(1); + expect(mockOpen).toHaveBeenCalled(); + }); + it("returns active when session file was recently modified", async () => { mockTmuxWithProcess("codex"); const content = '{"type":"session_meta","cwd":"/workspace/test"}\n'; @@ -980,6 +1045,121 @@ describe("getSessionInfo", () => { expect(await agent.getSessionInfo(makeSession())).toBeNull(); }); + it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => { + const sessionContent = jsonl( + { + type: "session_meta", + payload: { + id: "thread-fast-info", + }, + }, + { + type: "turn_context", + payload: { + model: "gpt-5.5", + }, + }, + ); + + mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-info.jsonl"]); + setupMockStream(sessionContent); + + const result = await agent.getSessionInfo( + makeSession({ + workspacePath: "/workspace/test", + metadata: { codexThreadId: "thread-fast-info" }, + }), + ); + + expect(result).not.toBeNull(); + expect(result!.agentSessionId).toBe("rollout-2026-05-22T00-00-00-thread-fast-info"); + expect(result!.summary).toBe("Codex session (gpt-5.5)"); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("caches codexThreadId filename lookups by thread id", async () => { + const sessionContent = jsonl({ + type: "session_meta", + payload: { + id: "thread-cached-info", + }, + }); + + mockReaddir.mockResolvedValue(["rollout-thread-cached-info.jsonl"]); + mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent)); + + const first = await agent.getSessionInfo( + makeSession({ + workspacePath: "/workspace/first", + metadata: { codexThreadId: "thread-cached-info" }, + }), + ); + const second = await agent.getSessionInfo( + makeSession({ + workspacePath: "/workspace/second", + metadata: { codexThreadId: "thread-cached-info" }, + }), + ); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(mockReaddir).toHaveBeenCalledTimes(1); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("chooses the newest duplicate codexThreadId filename match by mtime", async () => { + const oldContent = jsonl({ + type: "session_meta", + payload: { id: "thread-dupe", model: "old-model" }, + }); + const newContent = jsonl({ + type: "session_meta", + payload: { id: "thread-dupe", model: "new-model" }, + }); + + mockReaddir.mockResolvedValue([ + "rollout-old-thread-dupe.jsonl", + "rollout-new-thread-dupe.jsonl", + ]); + mockStat.mockImplementation((path: string) => { + if (path.includes("rollout-old-thread-dupe")) return Promise.resolve({ mtimeMs: 1000 }); + if (path.includes("rollout-new-thread-dupe")) return Promise.resolve({ mtimeMs: 2000 }); + return Promise.reject(new Error("ENOENT")); + }); + mockCreateReadStream.mockImplementation((path: string) => { + if (path.includes("rollout-old-thread-dupe")) return makeContentStream(oldContent); + if (path.includes("rollout-new-thread-dupe")) return makeContentStream(newContent); + return makeContentStream(""); + }); + + const result = await agent.getSessionInfo( + makeSession({ + workspacePath: "/workspace/test", + metadata: { codexThreadId: "thread-dupe" }, + }), + ); + + expect(result).not.toBeNull(); + expect(result!.agentSessionId).toBe("rollout-new-thread-dupe"); + expect(result!.summary).toBe("Codex session (new-model)"); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("does not treat infix filename matches as thread-id hits", async () => { + mockReaddir.mockResolvedValue(["rollout-thread-fast-extra.jsonl"]); + + const result = await agent.getSessionInfo( + makeSession({ + workspacePath: null, + metadata: { codexThreadId: "fast" }, + }), + ); + + expect(result).toBeNull(); + expect(mockOpen).not.toHaveBeenCalled(); + expect(mockCreateReadStream).not.toHaveBeenCalled(); + }); + it("returns null when no session files match the workspace cwd", async () => { mockReaddir.mockResolvedValue(["session-abc.jsonl"]); const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" }); @@ -990,6 +1170,25 @@ describe("getSessionInfo", () => { ).toBeNull(); }); + it("falls back to cwd-prefix scanning when codexThreadId is absent", async () => { + const sessionContent = jsonl({ + type: "session_meta", + cwd: "/workspace/test", + model: "gpt-5.4", + }); + + mockReaddir.mockResolvedValue(["rollout-cwd-fallback.jsonl"]); + setupMockOpen(sessionContent); + setupMockStream(sessionContent); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })); + + expect(result).not.toBeNull(); + expect(result!.summary).toBe("Codex session (gpt-5.4)"); + expect(mockOpen).toHaveBeenCalled(); + }); + it("returns session info with cost and model when matching session found", async () => { const sessionContent = jsonl( { type: "session_meta", cwd: "/workspace/test", model: "o3-mini" }, @@ -1212,6 +1411,31 @@ describe("getSessionInfo", () => { ).toBeNull(); }); + it("closes readline and destroys the stream when JSONL streaming is interrupted", async () => { + const content = jsonl({ type: "session_meta", cwd: "/workspace/test" }); + mockReaddir.mockResolvedValue(["sess.jsonl"]); + setupMockOpen(content); + mockStat.mockResolvedValue({ mtimeMs: 1000 }); + + const stream = makeContentStream(content); + const destroySpy = vi.spyOn(stream, "destroy"); + const closeSpy = vi.fn(); + mockCreateReadStream.mockReturnValue(stream); + mockCreateInterface.mockImplementationOnce(() => ({ + close: closeSpy, + async *[Symbol.asyncIterator]() { + yield JSON.stringify({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }); + throw new Error("aborted"); + }, + })); + + expect( + await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })), + ).toBeNull(); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(destroySpy).toHaveBeenCalledTimes(1); + }); + it("skips session files when stat throws", async () => { const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" }); mockReaddir.mockResolvedValue(["sess.jsonl"]); @@ -1371,6 +1595,7 @@ describe("getRestoreCommand", () => { expect(cmd).toContain("--model 'gpt-5.3-codex'"); expect(cmd).toContain("persisted-thread"); expect(mockReaddir).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); }); it("builds native resume command from payload-wrapped Codex session id", async () => { diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 93179d6d3..770c6a505 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -229,8 +229,11 @@ async function sessionFileMatchesCwd(filePath: string, workspacePath: string): P * Recursively scans ~/.codex/sessions/ (date-sharded: YYYY/MM/DD/rollout-*.jsonl). * Returns the path to the most recently modified matching file, or null. */ -async function findCodexSessionFile(workspacePath: string): Promise { - const jsonlFiles = await collectJsonlFiles(CODEX_SESSIONS_DIR); +async function findCodexSessionFile( + workspacePath: string, + jsonlFiles?: string[], +): Promise { + jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR); if (jsonlFiles.length === 0) return null; let bestMatch: { path: string; mtime: number } | null = null; @@ -252,6 +255,39 @@ async function findCodexSessionFile(workspacePath: string): Promise { + jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR); + const matches = jsonlFiles.filter((filePath) => + basename(filePath).endsWith(`-${threadId}.jsonl`), + ); + if (matches.length === 0) return null; + if (matches.length === 1) return matches[0] ?? null; + + let bestMatch: { path: string; mtime: number } | null = null; + let fallback: string | null = null; + for (const filePath of matches) { + fallback ??= filePath; + try { + const s = await stat(filePath); + if (!bestMatch || s.mtimeMs > bestMatch.mtime) { + bestMatch = { path: filePath, mtime: s.mtimeMs }; + } + } catch { + // Keep a filename match as fallback; thread id in the filename is enough. + } + } + + return bestMatch?.path ?? fallback; +} + /** Aggregated data extracted from a Codex session file via streaming */ interface CodexSessionData { model: string | null; @@ -268,6 +304,9 @@ interface CodexSessionData { * into memory. This is critical because Codex rollout files can be 100 MB+. */ async function streamCodexSessionData(filePath: string): Promise { + let stream: ReturnType | null = null; + let rl: ReturnType | null = null; + try { const data: CodexSessionData = { model: null, @@ -277,8 +316,9 @@ async function streamCodexSessionData(filePath: string): Promise(); -/** Find session file with caching to avoid double scans per refresh cycle */ -async function findCodexSessionFileCached(workspacePath: string): Promise { - const cached = sessionFileCache.get(workspacePath); +function getSessionMetadataString(session: Session, key: string): string | null { + const value = session.metadata?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +async function getCachedSessionFile( + cacheKey: string, + resolve: () => Promise, +): Promise { + const cached = sessionFileCache.get(cacheKey); if (cached && Date.now() < cached.expiry) { return cached.path; } - const result = await findCodexSessionFile(workspacePath); - sessionFileCache.set(workspacePath, { + const result = await resolve(); + sessionFileCache.set(cacheKey, { path: result, expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS, }); return result; } +/** Find session file with caching to avoid double scans per refresh cycle */ +async function findCodexSessionFileCached(session: Session): Promise { + let jsonlFiles: string[] | null = null; + const getJsonlFiles = async (): Promise => { + jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR); + return jsonlFiles; + }; + + const threadId = getSessionMetadataString(session, "codexThreadId"); + if (threadId) { + const byThreadId = await getCachedSessionFile(`thread:${threadId}`, async () => + findCodexSessionFileByThreadId(threadId, await getJsonlFiles()), + ); + if (byThreadId) return byThreadId; + } + + if (!session.workspacePath) return null; + return getCachedSessionFile(`cwd:${toComparablePath(session.workspacePath)}`, async () => + findCodexSessionFile(session.workspacePath!, await getJsonlFiles()), + ); +} + /** * Format a launch command for the host shell. On Windows the resolved binary * path is single-quoted by shellEscape (e.g. `'C:\Users\...\codex.cmd'`), and @@ -604,11 +676,13 @@ function createCodexAgent(): Agent { if (running === PROCESS_PROBE_INDETERMINATE) return null; if (!running) return { state: "exited", timestamp: exitedAt }; - if (!session.workspacePath) return null; + if (!session.workspacePath && !getSessionMetadataString(session, "codexThreadId")) { + return null; + } // 1. Try Codex's native JSONL first — it has richer 6-state detection // (approval_request, error, tool_call, etc.) that terminal parsing can't match. - const sessionFile = await findCodexSessionFileCached(session.workspacePath); + const sessionFile = await findCodexSessionFileCached(session); if (sessionFile) { const entry = await readLastJsonlEntry(sessionFile); if (entry) { @@ -669,7 +743,9 @@ function createCodexAgent(): Agent { // 2. Fallback: check AO activity JSONL (terminal-derived) for waiting_input/blocked // that the native JSONL may not have captured. - const activityResult = await readLastActivityEntry(session.workspacePath); + const activityResult = session.workspacePath + ? await readLastActivityEntry(session.workspacePath) + : null; const activityState = checkActivityLogState(activityResult); if (activityState) return activityState; @@ -758,9 +834,7 @@ function createCodexAgent(): Agent { }, async getSessionInfo(session: Session): Promise { - if (!session.workspacePath) return null; - - const sessionFile = await findCodexSessionFileCached(session.workspacePath); + const sessionFile = await findCodexSessionFileCached(session); if (!sessionFile) return null; // Stream the file line-by-line to avoid loading potentially huge @@ -799,13 +873,13 @@ function createCodexAgent(): Agent { }, async getRestoreCommand(session: Session, project: ProjectConfig): Promise { - let threadId = session.metadata?.["codexThreadId"]?.trim(); - let model: string | null = session.metadata?.["codexModel"]?.trim() || null; + let threadId = getSessionMetadataString(session, "codexThreadId"); + let model: string | null = getSessionMetadataString(session, "codexModel"); if (!threadId) { if (!session.workspacePath) return null; // Find the Codex session file for this workspace - const sessionFile = await findCodexSessionFileCached(session.workspacePath); + const sessionFile = await findCodexSessionFileCached(session); if (!sessionFile) return null; // Stream the file line-by-line to avoid loading potentially huge @@ -833,7 +907,10 @@ function createCodexAgent(): Agent { return formatLaunchCommand(parts); }, - async setupWorkspaceHooks(_workspacePath: string, _config: WorkspaceHooksConfig): Promise { + async setupWorkspaceHooks( + _workspacePath: string, + _config: WorkspaceHooksConfig, + ): Promise { // PATH wrappers are installed by session-manager for all agents. }, diff --git a/packages/plugins/agent-cursor/CHANGELOG.md b/packages/plugins/agent-cursor/CHANGELOG.md index 2d60850e0..378d383e2 100644 --- a/packages/plugins/agent-cursor/CHANGELOG.md +++ b/packages/plugins/agent-cursor/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-agent-cursor +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/agent-cursor/package.json b/packages/plugins/agent-cursor/package.json index 906630c79..14177b58e 100644 --- a/packages/plugins/agent-cursor/package.json +++ b/packages/plugins/agent-cursor/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-cursor", - "version": "0.8.0", + "version": "0.9.0", "description": "Agent plugin: Cursor CLI", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-grok/CHANGELOG.md b/packages/plugins/agent-grok/CHANGELOG.md new file mode 100644 index 000000000..51011dbdd --- /dev/null +++ b/packages/plugins/agent-grok/CHANGELOG.md @@ -0,0 +1,15 @@ +# @aoagents/ao-plugin-agent-grok + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 diff --git a/packages/plugins/agent-grok/package.json b/packages/plugins/agent-grok/package.json new file mode 100644 index 000000000..2f29eb542 --- /dev/null +++ b/packages/plugins/agent-grok/package.json @@ -0,0 +1,46 @@ +{ + "name": "@aoagents/ao-plugin-agent-grok", + "version": "0.1.1", + "description": "Agent plugin: Grok", + "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/agent-grok" + }, + "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:*", + "which": "^6.0.1" + }, + "devDependencies": { + "@types/which": "^3.0.4", + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/agent-grok/src/__tests__/index.test.ts b/packages/plugins/agent-grok/src/__tests__/index.test.ts new file mode 100644 index 000000000..6e181e7e3 --- /dev/null +++ b/packages/plugins/agent-grok/src/__tests__/index.test.ts @@ -0,0 +1,519 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { AgentLaunchConfig, RuntimeHandle, Session } from "@aoagents/ao-core"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const packageJson = require("../../package.json") as { + name: string; + version: string; + description: string; +}; +const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-"; +const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX) + ? packageJson.name.slice(PACKAGE_NAME_PREFIX.length) + : packageJson.name; + +const { + mockReadLastActivityEntry, + mockRecordTerminalActivity, + mockSetupPathWrapperWorkspace, + mockExecFileAsync, + mockWhichSync, + mockIsWindows, +} = vi.hoisted(() => ({ + mockReadLastActivityEntry: vi.fn().mockResolvedValue(null), + mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined), + mockSetupPathWrapperWorkspace: vi.fn().mockResolvedValue(undefined), + mockExecFileAsync: vi.fn(), + mockWhichSync: vi.fn(), + mockIsWindows: vi.fn(() => false), +})); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + readLastActivityEntry: mockReadLastActivityEntry, + recordTerminalActivity: mockRecordTerminalActivity, + setupPathWrapperWorkspace: mockSetupPathWrapperWorkspace, + isWindows: mockIsWindows, + }; +}); + +vi.mock("which", () => ({ + default: { + sync: mockWhichSync, + }, + sync: mockWhichSync, +})); + +vi.mock("node:child_process", () => ({ + execFile: (...args: unknown[]) => { + const callback = args[args.length - 1]; + const result = mockExecFileAsync(...args.slice(0, -1)); + if (typeof callback === "function" && result && typeof result.then === "function") { + result.then( + (value: { stdout: string; stderr: string }) => callback(null, value), + (err: Error) => callback(err), + ); + } + }, +})); + +import { create, detect, manifest, default as defaultExport } from "../index.js"; + +function makeSession(overrides: Partial = {}): Session { + return { + id: "test-1", + projectId: "test-project", + status: "working", + activity: "active", + activitySignal: { + state: "valid", + activity: "active", + timestamp: new Date(), + source: "runtime", + }, + lifecycle: {} as Session["lifecycle"], + branch: "feat/test", + issueId: null, + pr: null, + workspacePath: "/workspace/test", + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +function makeTmuxHandle(id = "test-session"): RuntimeHandle { + return { id, runtimeName: "tmux", data: {} }; +} + +function makeProcessHandle(pid?: number | string): RuntimeHandle { + return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} }; +} + +function makeLaunchConfig(overrides: Partial = {}): AgentLaunchConfig { + return { + sessionId: "sess-1", + projectConfig: { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { + model: "grok-4.1-fast", + }, + }, + ...overrides, + }; +} + +function makeActivityResult( + state: "active" | "ready" | "idle" | "waiting_input" | "blocked", + ts: Date, +): { + entry: { + state: "active" | "ready" | "idle" | "waiting_input" | "blocked"; + ts: string; + source: string; + }; + modifiedAt: Date; +} { + return { + entry: { + state, + ts: ts.toISOString(), + source: "terminal", + }, + modifiedAt: ts, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockWhichSync.mockReset(); + mockExecFileAsync.mockReset(); + mockIsWindows.mockReset(); + mockIsWindows.mockReturnValue(false); +}); + +describe("manifest", () => { + it("has correct Grok manifest", () => { + expect(manifest).toEqual({ + name: pluginName, + slot: "agent", + description: packageJson.description, + version: packageJson.version, + displayName: "Grok", + }); + }); +}); + +describe("create", () => { + it("uses grok as process name and post-launch prompt mode", () => { + const agent = create(); + expect(agent.name).toBe(pluginName); + expect(agent.processName).toBe(pluginName); + expect(agent.promptDelivery).toBe("post-launch"); + }); + + it("exports plugin module shape", () => { + expect(defaultExport.manifest).toBe(manifest); + expect(typeof defaultExport.create).toBe("function"); + }); +}); + +describe("detect", () => { + it("returns true when which resolves", () => { + mockWhichSync.mockReturnValue("/usr/local/bin/grok"); + expect(detect()).toBe(true); + }); + + it("returns false when which fails", () => { + mockWhichSync.mockImplementation(() => { + throw new Error("not found"); + }); + expect(detect()).toBe(false); + }); +}); + +describe("getLaunchCommand", () => { + const agent = create(); + + it("uses interactive launch without a session id", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ projectConfig: { ...makeLaunchConfig().projectConfig, agentConfig: {} } }), + ); + expect(cmd).toBe("grok --no-alt-screen --worktree"); + }); + + it("uses configured model and rules file when provided", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + model: "grok-4.1", + systemPromptFile: "/tmp/ao prompt.md", + }), + ); + expect(cmd).toBe( + "grok --no-alt-screen --worktree --model 'grok-4.1' --rules '@/tmp/ao prompt.md'", + ); + }); + + it("uses --resume when a configured Grok session id is present", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + projectConfig: { + ...makeLaunchConfig().projectConfig, + agentConfig: { grokSessionId: "01HXGROKSESSION" }, + }, + }), + ); + expect(cmd).toBe("grok --no-alt-screen --resume '01HXGROKSESSION'"); + }); + + it("does not include prompt flags when prompt delivery is post-launch", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + prompt: "Do work", + systemPrompt: "You are helpful", + }), + ); + expect(cmd).toContain("--rules 'You are helpful'"); + expect(cmd).not.toContain("-p"); + expect(cmd).not.toContain("--single"); + expect(cmd).not.toContain("--prompt"); + }); +}); + +describe("getEnvironment", () => { + const agent = create(); + + it("writes AO session keys and leaves shared wrapper paths to session-manager", () => { + const env = agent.getEnvironment(makeLaunchConfig()); + expect(env["AO_SESSION_ID"]).toBe("sess-1"); + expect(env["AO_ISSUE_ID"]).toBeUndefined(); + expect(env["PATH"]).toBeUndefined(); + expect(env["GH_PATH"]).toBeUndefined(); + expect(env["GROK_SANDBOX"]).toBeUndefined(); + }); + + it("includes AO_ISSUE_ID and GROK_SANDBOX when provided", () => { + const env = agent.getEnvironment( + makeLaunchConfig({ + issueId: "INT-42", + projectConfig: { + ...makeLaunchConfig().projectConfig, + agentConfig: { grokSandbox: "workspace-write" }, + }, + }), + ); + expect(env["AO_ISSUE_ID"]).toBe("INT-42"); + expect(env["GROK_SANDBOX"]).toBe("workspace-write"); + }); +}); + +describe("isProcessRunning", () => { + const agent = create(); + + it("returns true when grok is on tmux pane", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") { + return Promise.resolve({ + stdout: " PID TT ARGS\n 789 ttys003 /Users/me/.grok/bin/grok\n", + stderr: "", + }); + } + return Promise.reject(new Error("unexpected")); + }); + + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); + }); + + it("returns false when tmux process is missing", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") + return Promise.resolve({ stdout: " PID TT ARGS\n 789 ttys003 bash\n", stderr: "" }); + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + }); + + it("returns indeterminate when tmux probing fails", async () => { + mockExecFileAsync.mockRejectedValue(new Error("tmux unavailable")); + + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + }); + + it("returns indeterminate for tmux handles on Windows", async () => { + mockIsWindows.mockReturnValue(true); + + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate"); + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); + + it("returns true when process handle pid is alive", async () => { + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true); + expect(killSpy).toHaveBeenCalledWith(123, 0); + killSpy.mockRestore(); + }); + + it("treats EPERM as running for process handles", async () => { + const err = Object.assign(new Error("EPERM"), { code: "EPERM" }); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw err; + }); + expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true); + killSpy.mockRestore(); + }); + + it("returns false when process handle pid is dead", async () => { + const err = Object.assign(new Error("ESRCH"), { code: "ESRCH" }); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw err; + }); + expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(false); + killSpy.mockRestore(); + }); +}); + +describe("recordActivity", () => { + const agent = create(); + + it("classifies idle terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "foo\n› "); + expect(mockRecordTerminalActivity).toHaveBeenCalledWith( + "/workspace/test", + "foo\n› ", + expect.any(Function), + ); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("foo\n› ")).toBe("idle"); + }); + + it("classifies active terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "Thinking through the plan"); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("Thinking through the plan")).toBe("active"); + }); + + it("classifies waiting_input terminal output when recording activity", async () => { + await agent.recordActivity?.( + makeSession(), + "Signing in with Grok...\n\nOpen this URL to sign in:\n https://auth.x.ai/oauth2/authorize?...", + ); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("Allow this command? y/N:")).toBe("waiting_input"); + expect(classify?.("Open this URL to sign in: https://auth.x.ai/oauth2/authorize")).toBe( + "waiting_input", + ); + }); + + it("classifies blocked terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "Error: Device not configured"); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("Error: Device not configured")).toBe("blocked"); + }); + + it("skips recording when workspacePath is missing", async () => { + await agent.recordActivity?.(makeSession({ workspacePath: null }), "still running"); + expect(mockRecordTerminalActivity).not.toHaveBeenCalled(); + }); +}); + +describe("getActivityState", () => { + const agent = create(); + + it("falls back to exited when runtime handle is missing", async () => { + const state = await agent.getActivityState(makeSession({ runtimeHandle: null })); + expect(state).toMatchObject({ state: "exited" }); + }); + + it("returns waiting_input from recent activity JSONL", async () => { + const activityAt = new Date(); + mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("waiting_input", activityAt)); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ runtimeHandle: makeProcessHandle(101) }), + ); + expect(state?.state).toBe("waiting_input"); + killSpy.mockRestore(); + }); + + it("returns blocked from recent activity JSONL", async () => { + const activityAt = new Date(); + mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("blocked", activityAt)); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ runtimeHandle: makeProcessHandle(101) }), + ); + expect(state?.state).toBe("blocked"); + expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString()); + killSpy.mockRestore(); + }); + + it("returns active from JSONL fallback", async () => { + const activityAt = new Date(); + mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt)); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ runtimeHandle: makeProcessHandle(101) }), + ); + expect(state?.state).toBe("active"); + expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString()); + killSpy.mockRestore(); + }); + + it("decays JSONL fallback state to idle when the entry is stale", async () => { + const activityAt = new Date(Date.now() - 24 * 60 * 60 * 1000); + mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt)); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ runtimeHandle: makeProcessHandle(101) }), + ); + expect(state?.state).toBe("idle"); + expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString()); + killSpy.mockRestore(); + }); + + it("returns null when no activity data is available", async () => { + mockReadLastActivityEntry.mockResolvedValue(null); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ runtimeHandle: makeProcessHandle(101) }), + ); + expect(state).toBeNull(); + killSpy.mockRestore(); + }); + + it("treats indeterminate process probes as no process verdict", async () => { + mockReadLastActivityEntry.mockResolvedValue(null); + mockExecFileAsync.mockRejectedValue(new Error("tmux unavailable")); + + const state = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); + + expect(state).toBeNull(); + }); +}); + +describe("getSessionInfo", () => { + const agent = create(); + + it("returns null without Grok session metadata", async () => { + expect(await agent.getSessionInfo(makeSession())).toBeNull(); + }); + + it("returns a known Grok session id from metadata", async () => { + const info = await agent.getSessionInfo( + makeSession({ metadata: { grokSessionId: "01HXGROKSESSION" } }), + ); + expect(info).toEqual({ + agentSessionId: "01HXGROKSESSION", + summary: null, + }); + }); +}); + +describe("getRestoreCommand", () => { + const agent = create(); + + it("returns null without Grok session metadata", async () => { + const cmd = await agent.getRestoreCommand?.(makeSession(), makeLaunchConfig().projectConfig); + expect(cmd).toBeNull(); + }); + + it("builds restore command using the Grok session id", async () => { + const cmd = await agent.getRestoreCommand?.( + makeSession({ metadata: { grokSessionId: "01HXGROKSESSION" } }), + makeLaunchConfig({ model: "grok-4.1" }).projectConfig, + ); + expect(cmd).toBe("grok --no-alt-screen --model 'grok-4.1-fast' --resume '01HXGROKSESSION'"); + }); +}); + +describe("workspace hooks", () => { + const agent = create(); + + it("sets up PATH wrapper workspace hooks", async () => { + await agent.setupWorkspaceHooks?.("/workspace/test", { dataDir: "/sessions" }); + expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test"); + }); + + it("runs post-launch workspace hook setup", async () => { + await agent.postLaunchSetup?.(makeSession({ workspacePath: "/workspace/test" })); + expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test"); + }); + + it("waits for Grok worktree readiness before post-launch prompt delivery", async () => { + mockExecFileAsync.mockResolvedValue({ + stdout: "Worktree ready: /Users/me/.grok/worktrees/project/smoke\n", + stderr: "", + }); + + await agent.postLaunchSetup?.( + makeSession({ workspacePath: "/workspace/test", runtimeHandle: makeTmuxHandle("ao-smoke") }), + ); + + expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test"); + expect(mockExecFileAsync).toHaveBeenCalledWith( + "tmux", + ["capture-pane", "-t", "ao-smoke", "-p", "-S", "-120"], + { timeout: 5_000 }, + ); + }); +}); diff --git a/packages/plugins/agent-grok/src/index.ts b/packages/plugins/agent-grok/src/index.ts new file mode 100644 index 000000000..084fac296 --- /dev/null +++ b/packages/plugins/agent-grok/src/index.ts @@ -0,0 +1,316 @@ +import { + DEFAULT_READY_THRESHOLD_MS, + DEFAULT_ACTIVE_WINDOW_MS, + PROCESS_PROBE_INDETERMINATE, + shellEscape, + readLastActivityEntry, + checkActivityLogState, + getActivityFallbackState, + recordTerminalActivity, + setupPathWrapperWorkspace, + isWindows, + type Agent, + type AgentSessionInfo, + type AgentLaunchConfig, + type ActivityDetection, + type ActivityState, + type PluginModule, + type ProcessProbeResult, + type ProjectConfig, + type RuntimeHandle, + type Session, + type WorkspaceHooksConfig, +} from "@aoagents/ao-core"; +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { setTimeout as sleep } from "node:timers/promises"; +import { promisify } from "node:util"; +import which from "which"; + +const require = createRequire(import.meta.url); +const packageJson = require("../package.json") as { + name: string; + version: string; + description: string; +}; +const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-"; +const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX) + ? packageJson.name.slice(PACKAGE_NAME_PREFIX.length) + : packageJson.name; + +const execFileAsync = promisify(execFile); +const GROK_EXECUTABLE = "grok"; +const GROK_STARTUP_READY_TIMEOUT_MS = 30_000; +const GROK_STARTUP_POLL_MS = 500; +const ANSI_ESCAPE_RE = new RegExp( + `${String.fromCharCode(27)}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])`, + "g", +); +const GROK_CONFIRM_PROMPT_RE = + /(?:allow|approve|do you want to continue|continue anyway|proceed).*(?:y\/n|yes\/no|\[[YyNn]\/ ?[YyNn]\]|\([YyNn]\/ ?[YyNn]\)|\?)\s*:?$/i; +const GROK_AUTH_PROMPT_RE = /(?:sign in with grok|open this url to sign in|oauth2\/authorize)/i; + +interface GrokAgentConfig { + grokSessionId?: unknown; + model?: unknown; + grokSandbox?: unknown; +} + +function asGrokSessionId(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + if (/\p{C}/u.test(trimmed)) return null; + return trimmed.length <= 512 ? trimmed : null; +} + +function getConfiguredGrokSessionId(config: AgentLaunchConfig): string | null { + return asGrokSessionId( + (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.grokSessionId, + ); +} + +function getConfiguredModel(config: AgentLaunchConfig): string | null { + const model = + config.model ?? (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.model; + return typeof model === "string" && model.trim() ? model.trim() : null; +} + +function getConfiguredSandbox(config: AgentLaunchConfig): string | null { + const sandbox = (config.projectConfig.agentConfig as GrokAgentConfig | undefined)?.grokSandbox; + return typeof sandbox === "string" && sandbox.trim() ? sandbox.trim() : null; +} + +function buildGrokCommand(config: AgentLaunchConfig, sessionId?: string | null): string { + const restoreSessionId = sessionId ?? getConfiguredGrokSessionId(config); + const parts = [GROK_EXECUTABLE, "--no-alt-screen"]; + if (!restoreSessionId) { + parts.push("--worktree"); + } + const model = getConfiguredModel(config); + if (model) { + parts.push("--model", shellEscape(model)); + } + if (config.systemPromptFile) { + parts.push("--rules", shellEscape(`@${config.systemPromptFile}`)); + } else if (config.systemPrompt) { + parts.push("--rules", shellEscape(config.systemPrompt)); + } + if (restoreSessionId) { + parts.push("--resume", shellEscape(restoreSessionId)); + } + return parts.join(" "); +} + +async function captureTmuxOutput(handle: RuntimeHandle): Promise { + const { stdout } = await execFileAsync( + "tmux", + ["capture-pane", "-t", handle.id, "-p", "-S", "-120"], + { timeout: 5_000 }, + ); + return stdout; +} + +async function waitForGrokWorktreeReady(session: Session): Promise { + const handle = session.runtimeHandle; + if (!handle || handle.runtimeName !== "tmux" || !handle.id) return; + if (isWindows()) return; + if (asGrokSessionId(session.metadata?.grokSessionId)) return; + + const startedAt = Date.now(); + while (Date.now() - startedAt < GROK_STARTUP_READY_TIMEOUT_MS) { + const output = await captureTmuxOutput(handle); + if (/Worktree ready:/i.test(output)) return; + await sleep(GROK_STARTUP_POLL_MS); + } +} + +function classifyGrokTerminalOutput(terminalOutput: string): ActivityState { + const normalizedOutput = terminalOutput.replaceAll(ANSI_ESCAPE_RE, "").trim(); + if (!normalizedOutput) return "idle"; + + const lines = normalizedOutput.split("\n").map((line) => line.trim()); + const lastLine = lines[lines.length - 1] ?? ""; + const lastNonEmptyLine = [...lines].reverse().find(Boolean) ?? ""; + + if (/^[>$#›]\s*$/.test(lastLine)) return "idle"; + if (GROK_AUTH_PROMPT_RE.test(normalizedOutput)) return "waiting_input"; + if (GROK_CONFIRM_PROMPT_RE.test(lastNonEmptyLine)) return "waiting_input"; + if (/\b(error|failed|exception|not authenticated|device not configured)\b/i.test(lastLine)) + return "blocked"; + + return "active"; +} + +export const manifest = { + name: pluginName, + slot: "agent" as const, + description: packageJson.description, + version: packageJson.version, + displayName: "Grok", +}; + +function createGrokAgent(): Agent { + return { + name: pluginName, + processName: pluginName, + promptDelivery: "post-launch", + + getLaunchCommand(config: AgentLaunchConfig): string { + return buildGrokCommand(config); + }, + + getEnvironment(config: AgentLaunchConfig): Record { + const env: Record = {}; + env["AO_SESSION_ID"] = config.sessionId; + if (config.issueId) { + env["AO_ISSUE_ID"] = config.issueId; + } + + const sandbox = getConfiguredSandbox(config); + if (sandbox) { + env["GROK_SANDBOX"] = sandbox; + } + + return env; + }, + + detectActivity(terminalOutput: string): ActivityState { + return classifyGrokTerminalOutput(terminalOutput); + }, + + async getActivityState( + session: Session, + readyThresholdMs?: number, + ): Promise { + const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + + const exitedAt = new Date(); + if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; + const running = await this.isProcessRunning(session.runtimeHandle); + if (running === false) return { state: "exited", timestamp: exitedAt }; + + let activityResult: Awaited> = null; + if (session.workspacePath) { + activityResult = await readLastActivityEntry(session.workspacePath); + const activityState = checkActivityLogState(activityResult); + if (activityState) return activityState; + } + + const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (fallback) return fallback; + + return null; + }, + + async recordActivity(session: Session, terminalOutput: string): Promise { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output: string) => + classifyGrokTerminalOutput(output), + ); + }, + + async isProcessRunning(handle: RuntimeHandle): Promise { + try { + if (handle.runtimeName === "tmux" && handle.id) { + if (isWindows()) return PROCESS_PROBE_INDETERMINATE; + 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 false; + + const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }); + const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); + const processRe = /(?:^|[\s/])\.?grok(?:\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 true; + } + } + return false; + } + + 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 true; + } catch (err: unknown) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "EPERM") { + return true; + } + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ESRCH") { + return false; + } + return PROCESS_PROBE_INDETERMINATE; + } + } + return false; + } catch { + return PROCESS_PROBE_INDETERMINATE; + } + }, + + async getSessionInfo(session: Session): Promise { + const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId); + if (!grokSessionId) return null; + return { + agentSessionId: grokSessionId, + summary: null, + }; + }, + + async getRestoreCommand(session: Session, project: ProjectConfig): Promise { + const grokSessionId = asGrokSessionId(session.metadata?.grokSessionId); + if (!grokSessionId) return null; + return buildGrokCommand( + { + sessionId: session.id, + projectConfig: project, + workspacePath: session.workspacePath ?? undefined, + issueId: session.issueId ?? undefined, + }, + grokSessionId, + ); + }, + + async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { + await setupPathWrapperWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + if (session.workspacePath) { + await setupPathWrapperWorkspace(session.workspacePath); + } + await waitForGrokWorktreeReady(session); + }, + }; +} + +export function create(): Agent { + return createGrokAgent(); +} + +export function detect(): boolean { + try { + return Boolean(which.sync(GROK_EXECUTABLE)); + } catch { + return false; + } +} + +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/agent-grok/tsconfig.json b/packages/plugins/agent-grok/tsconfig.json new file mode 100644 index 000000000..556dd3e4e --- /dev/null +++ b/packages/plugins/agent-grok/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/plugins/agent-kimicode/CHANGELOG.md b/packages/plugins/agent-kimicode/CHANGELOG.md index c2eeaab2c..1a181913a 100644 --- a/packages/plugins/agent-kimicode/CHANGELOG.md +++ b/packages/plugins/agent-kimicode/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-agent-kimicode +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/agent-kimicode/package.json b/packages/plugins/agent-kimicode/package.json index 30c09fa89..71a3e545c 100644 --- a/packages/plugins/agent-kimicode/package.json +++ b/packages/plugins/agent-kimicode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-kimicode", - "version": "0.8.0", + "version": "0.9.0", "description": "Agent plugin: Kimi Code CLI (MoonshotAI)", "license": "MIT", "type": "module", diff --git a/packages/plugins/agent-kimicode/src/index.ts b/packages/plugins/agent-kimicode/src/index.ts index a0b26b9f6..2711bafa4 100644 --- a/packages/plugins/agent-kimicode/src/index.ts +++ b/packages/plugins/agent-kimicode/src/index.ts @@ -52,9 +52,12 @@ async function extractKimiSummary(sessionDir: string): Promise { // still be planted as symlinks pointing at /etc/passwd or /dev/zero. if (!(await isKimiSessionFile(wirePath))) return null; let summary: string | null = null; + let stream: ReturnType | null = null; + let rl: ReturnType | null = null; try { - const rl = createInterface({ - input: createReadStream(wirePath, { encoding: "utf-8" }), + stream = createReadStream(wirePath, { encoding: "utf-8" }); + rl = createInterface({ + input: stream, crlfDelay: Infinity, }); let bytes = 0; @@ -85,9 +88,11 @@ async function extractKimiSummary(sessionDir: string): Promise { // Skip malformed line } } - rl.close(); } catch { return null; + } finally { + rl?.close(); + stream?.destroy(); } return summary; } diff --git a/packages/plugins/agent-opencode/CHANGELOG.md b/packages/plugins/agent-opencode/CHANGELOG.md index db22d59f2..eb6e5abf1 100644 --- a/packages/plugins/agent-opencode/CHANGELOG.md +++ b/packages/plugins/agent-opencode/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-agent-opencode +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Minor Changes diff --git a/packages/plugins/agent-opencode/package.json b/packages/plugins/agent-opencode/package.json index 16e3ba5a6..b1e5b7865 100644 --- a/packages/plugins/agent-opencode/package.json +++ b/packages/plugins/agent-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-agent-opencode", - "version": "0.8.0", + "version": "0.9.0", "description": "Agent plugin: OpenCode", "license": "MIT", "type": "module", diff --git a/packages/plugins/notifier-composio/CHANGELOG.md b/packages/plugins/notifier-composio/CHANGELOG.md index ec8d20f5c..ce0be2d0e 100644 --- a/packages/plugins/notifier-composio/CHANGELOG.md +++ b/packages/plugins/notifier-composio/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-notifier-composio +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index ef78c917f..ba6709226 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-composio", - "version": "0.8.0", + "version": "0.9.0", "description": "Notifier plugin: Composio unified notifications (Slack, Discord, email)", "license": "MIT", "type": "module", @@ -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/CHANGELOG.md b/packages/plugins/notifier-dashboard/CHANGELOG.md new file mode 100644 index 000000000..cab113736 --- /dev/null +++ b/packages/plugins/notifier-dashboard/CHANGELOG.md @@ -0,0 +1,16 @@ +# @aoagents/ao-plugin-notifier-dashboard + +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 diff --git a/packages/plugins/notifier-dashboard/package.json b/packages/plugins/notifier-dashboard/package.json new file mode 100644 index 000000000..f10fdbd65 --- /dev/null +++ b/packages/plugins/notifier-dashboard/package.json @@ -0,0 +1,44 @@ +{ + "name": "@aoagents/ao-plugin-notifier-dashboard", + "version": "0.9.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/CHANGELOG.md b/packages/plugins/notifier-desktop/CHANGELOG.md index 79b929fde..918ee5c02 100644 --- a/packages/plugins/notifier-desktop/CHANGELOG.md +++ b/packages/plugins/notifier-desktop/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-notifier-desktop +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/notifier-desktop/package.json b/packages/plugins/notifier-desktop/package.json index 003280b7c..e18a55b87 100644 --- a/packages/plugins/notifier-desktop/package.json +++ b/packages/plugins/notifier-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-desktop", - "version": "0.8.0", + "version": "0.9.0", "description": "Notifier plugin: OS desktop notifications", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/plugins/notifier-discord/CHANGELOG.md index e067ee0e1..da9dca1d8 100644 --- a/packages/plugins/notifier-discord/CHANGELOG.md +++ b/packages/plugins/notifier-discord/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-notifier-discord +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/notifier-discord/package.json b/packages/plugins/notifier-discord/package.json index 21c0ef29e..820437d40 100644 --- a/packages/plugins/notifier-discord/package.json +++ b/packages/plugins/notifier-discord/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-discord", - "version": "0.8.0", + "version": "0.9.0", "description": "Notifier plugin: Discord webhook notifications", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/plugins/notifier-openclaw/CHANGELOG.md index f392d06a9..65aecfb24 100644 --- a/packages/plugins/notifier-openclaw/CHANGELOG.md +++ b/packages/plugins/notifier-openclaw/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-notifier-openclaw +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes 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/package.json b/packages/plugins/notifier-openclaw/package.json index 2b03b6369..5f1cb4314 100644 --- a/packages/plugins/notifier-openclaw/package.json +++ b/packages/plugins/notifier-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-openclaw", - "version": "0.8.0", + "version": "0.9.0", "description": "Notifier plugin: OpenClaw webhook notifications", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/plugins/notifier-slack/CHANGELOG.md index b0ddc06b1..7e501f0b7 100644 --- a/packages/plugins/notifier-slack/CHANGELOG.md +++ b/packages/plugins/notifier-slack/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-notifier-slack +## 0.9.0 + +### Patch Changes + +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/notifier-slack/package.json b/packages/plugins/notifier-slack/package.json index cbf23bd85..85ffb1064 100644 --- a/packages/plugins/notifier-slack/package.json +++ b/packages/plugins/notifier-slack/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-slack", - "version": "0.8.0", + "version": "0.9.0", "description": "Notifier plugin: Slack", "license": "MIT", "type": "module", 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/notifier-webhook/CHANGELOG.md b/packages/plugins/notifier-webhook/CHANGELOG.md index 6288a0aa3..e0a994df1 100644 --- a/packages/plugins/notifier-webhook/CHANGELOG.md +++ b/packages/plugins/notifier-webhook/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-notifier-webhook +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/notifier-webhook/package.json b/packages/plugins/notifier-webhook/package.json index c6a0b545f..b9a8db88b 100644 --- a/packages/plugins/notifier-webhook/package.json +++ b/packages/plugins/notifier-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-notifier-webhook", - "version": "0.8.0", + "version": "0.9.0", "description": "Notifier plugin: generic webhook", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-process/CHANGELOG.md b/packages/plugins/runtime-process/CHANGELOG.md index bda98972b..19a25a47b 100644 --- a/packages/plugins/runtime-process/CHANGELOG.md +++ b/packages/plugins/runtime-process/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-runtime-process +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 692ffb065..665257b09 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-process", - "version": "0.8.0", + "version": "0.9.0", "description": "Runtime plugin: child processes", "license": "MIT", "type": "module", diff --git a/packages/plugins/runtime-tmux/CHANGELOG.md b/packages/plugins/runtime-tmux/CHANGELOG.md index 87ad7b266..1efab2b8f 100644 --- a/packages/plugins/runtime-tmux/CHANGELOG.md +++ b/packages/plugins/runtime-tmux/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-runtime-tmux +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index d32591794..c61b984ee 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-runtime-tmux", - "version": "0.8.0", + "version": "0.9.0", "description": "Runtime plugin: tmux sessions", "license": "MIT", "type": "module", diff --git a/packages/plugins/scm-github/CHANGELOG.md b/packages/plugins/scm-github/CHANGELOG.md index f989e12f2..379c286bb 100644 --- a/packages/plugins/scm-github/CHANGELOG.md +++ b/packages/plugins/scm-github/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-scm-github +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index 75f262e7f..11d11631c 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-github", - "version": "0.8.0", + "version": "0.9.0", "description": "SCM plugin: GitHub (PRs, CI, reviews)", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/plugins/scm-gitlab/CHANGELOG.md index 4e55944ea..eebdfeedd 100644 --- a/packages/plugins/scm-gitlab/CHANGELOG.md +++ b/packages/plugins/scm-gitlab/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-scm-gitlab +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/scm-gitlab/package.json b/packages/plugins/scm-gitlab/package.json index 9367e5177..dc674a61c 100644 --- a/packages/plugins/scm-gitlab/package.json +++ b/packages/plugins/scm-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-scm-gitlab", - "version": "0.8.0", + "version": "0.9.0", "description": "SCM plugin: GitLab (MRs, CI, reviews)", "license": "MIT", "type": "module", 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/terminal-iterm2/CHANGELOG.md b/packages/plugins/terminal-iterm2/CHANGELOG.md index f11718dc8..395c037f7 100644 --- a/packages/plugins/terminal-iterm2/CHANGELOG.md +++ b/packages/plugins/terminal-iterm2/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-terminal-iterm2 +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/terminal-iterm2/package.json b/packages/plugins/terminal-iterm2/package.json index b8472a40b..3c30f22de 100644 --- a/packages/plugins/terminal-iterm2/package.json +++ b/packages/plugins/terminal-iterm2/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-iterm2", - "version": "0.8.0", + "version": "0.9.0", "description": "Terminal plugin: macOS iTerm2", "license": "MIT", "type": "module", diff --git a/packages/plugins/terminal-web/CHANGELOG.md b/packages/plugins/terminal-web/CHANGELOG.md index d99d4c95e..1f7abc4b7 100644 --- a/packages/plugins/terminal-web/CHANGELOG.md +++ b/packages/plugins/terminal-web/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-terminal-web +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/terminal-web/package.json b/packages/plugins/terminal-web/package.json index b0743fa88..c43debf39 100644 --- a/packages/plugins/terminal-web/package.json +++ b/packages/plugins/terminal-web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-terminal-web", - "version": "0.8.0", + "version": "0.9.0", "description": "Terminal plugin: xterm.js web terminal", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-github/CHANGELOG.md b/packages/plugins/tracker-github/CHANGELOG.md index 012d22bc3..a3da5d1d7 100644 --- a/packages/plugins/tracker-github/CHANGELOG.md +++ b/packages/plugins/tracker-github/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-tracker-github +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 501e2d014..7e52dd323 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-github", - "version": "0.8.0", + "version": "0.9.0", "description": "Tracker plugin: GitHub Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-gitlab/CHANGELOG.md b/packages/plugins/tracker-gitlab/CHANGELOG.md index 67aa2b0be..92e45a446 100644 --- a/packages/plugins/tracker-gitlab/CHANGELOG.md +++ b/packages/plugins/tracker-gitlab/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-tracker-gitlab +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + - @aoagents/ao-plugin-scm-gitlab@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/tracker-gitlab/package.json b/packages/plugins/tracker-gitlab/package.json index 0275eb208..f45354d20 100644 --- a/packages/plugins/tracker-gitlab/package.json +++ b/packages/plugins/tracker-gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-gitlab", - "version": "0.8.0", + "version": "0.9.0", "description": "Tracker plugin: GitLab Issues", "license": "MIT", "type": "module", diff --git a/packages/plugins/tracker-linear/CHANGELOG.md b/packages/plugins/tracker-linear/CHANGELOG.md index 57c285650..55283ee0f 100644 --- a/packages/plugins/tracker-linear/CHANGELOG.md +++ b/packages/plugins/tracker-linear/CHANGELOG.md @@ -1,5 +1,20 @@ # @aoagents/ao-plugin-tracker-linear +## 0.9.0 + +### Patch Changes + +- 6d48022: Retry transient Linear API HTTP failures in the direct transport to reduce flakes from brief 5xx/429 responses. +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index baee198ec..08831473d 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-tracker-linear", - "version": "0.8.0", + "version": "0.9.0", "description": "Tracker plugin: Linear", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/plugins/workspace-clone/CHANGELOG.md index fc4342852..322ba52aa 100644 --- a/packages/plugins/workspace-clone/CHANGELOG.md +++ b/packages/plugins/workspace-clone/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-workspace-clone +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index bb8668e84..762412b65 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-clone", - "version": "0.8.0", + "version": "0.9.0", "description": "Workspace plugin: git clone", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/plugins/workspace-worktree/CHANGELOG.md index 776b1a984..f7956c448 100644 --- a/packages/plugins/workspace-worktree/CHANGELOG.md +++ b/packages/plugins/workspace-worktree/CHANGELOG.md @@ -1,5 +1,19 @@ # @aoagents/ao-plugin-workspace-worktree +## 0.9.0 + +### Patch Changes + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + ## 0.8.0 ### Patch Changes diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index 61b2459e0..20503bb29 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-plugin-workspace-worktree", - "version": "0.8.0", + "version": "0.9.0", "description": "Workspace plugin: git worktrees", "license": "MIT", "type": "module", 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/CHANGELOG.md b/packages/web/CHANGELOG.md index cd79e9e9b..6fb5ac3e2 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -1,5 +1,69 @@ # @aoagents/ao-web +## 0.9.0 + +### Minor Changes + +- 73bed33: Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620). + - `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature) + - `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body) + - `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body) + - `api.webhook_failed` (error) — outer pipeline crash with `errorMessage` + - `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle + - `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only) + - `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser") + - `ui.terminal_protocol_error` (warn) — invalid mux client message + - `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min + + `api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window. + +- 94981dc: feat: "Launch Orchestrator (clean context)" action on the orchestrator session page + + Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080. + +### Patch Changes + +- ee3fb5d: 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`. +- 2980570: Add the notifier test harness, dashboard notifications, and desktop notifier setup. +- 07c9099: 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. + +- Updated dependencies [73bed33] +- Updated dependencies [a610601] +- Updated dependencies [8c71bde] +- Updated dependencies [7d9b862] +- Updated dependencies [6d48022] +- Updated dependencies [fcedb25] +- Updated dependencies [94981dc] +- Updated dependencies [6d48022] +- Updated dependencies [2980570] +- Updated dependencies [d5d0f07] + - @aoagents/ao-core@0.9.0 + - @aoagents/ao-plugin-agent-claude-code@0.9.0 + - @aoagents/ao-plugin-tracker-linear@0.9.0 + - @aoagents/ao-plugin-agent-codex@0.9.0 + - @aoagents/ao-plugin-agent-cursor@0.9.0 + - @aoagents/ao-plugin-agent-grok@0.1.1 + - @aoagents/ao-plugin-agent-kimicode@0.9.0 + - @aoagents/ao-plugin-agent-opencode@0.9.0 + - @aoagents/ao-plugin-runtime-process@0.9.0 + - @aoagents/ao-plugin-runtime-tmux@0.9.0 + - @aoagents/ao-plugin-scm-github@0.9.0 + - @aoagents/ao-plugin-tracker-github@0.9.0 + - @aoagents/ao-plugin-workspace-worktree@0.9.0 + ## 0.8.0 ### Patch Changes 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/next.config.js b/packages/web/next.config.js index 5923fedff..2639442dc 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -9,7 +9,6 @@ const homeDir = os.homedir().replace(/\\/g, "/"); const nextConfig = { outputFileTracingRoot: path.join(__dirname, "../.."), transpilePackages: [ - "@aoagents/ao-core", "@aoagents/ao-plugin-agent-claude-code", "@aoagents/ao-plugin-agent-codex", "@aoagents/ao-plugin-agent-opencode", @@ -22,6 +21,8 @@ const nextConfig = { serverExternalPackages: [ "yaml", "zod", + "@aoagents/ao-core", + "better-sqlite3", ], webpack: (config, { isServer }) => { if (process.platform === "win32") { diff --git a/packages/web/package.json b/packages/web/package.json index 83a59ace5..79b26d4a1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@aoagents/ao-web", - "version": "0.8.0", + "version": "0.9.0", "description": "Web dashboard for agent-orchestrator", "license": "MIT", "type": "module", @@ -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", @@ -46,6 +47,7 @@ "@aoagents/ao-plugin-agent-claude-code": "workspace:*", "@aoagents/ao-plugin-agent-codex": "workspace:*", "@aoagents/ao-plugin-agent-cursor": "workspace:*", + "@aoagents/ao-plugin-agent-grok": "workspace:*", "@aoagents/ao-plugin-agent-kimicode": "workspace:*", "@aoagents/ao-plugin-agent-opencode": "workspace:*", "@aoagents/ao-plugin-runtime-process": "workspace:*", diff --git a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts index 39f2caaa6..236a0f84e 100644 --- a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts +++ b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts @@ -180,6 +180,19 @@ describeWithTmux("WebSocket upgrade routing", () => { ws.close(); }); + it("accepts connections on /ao-terminal-mux (alias for /mux)", async () => { + // The dashboard's MuxProvider uses this path on standard-port deployments + // so a path-routing reverse proxy can forward it here without a rewrite. + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://localhost:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000); + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + it("destroys connections on unknown paths", async () => { const result = await new Promise<{ code: number }>((resolve) => { const ws = new WebSocket(`ws://localhost:${port}/ws`); diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index 48f38e96f..3f5cc0216 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 { chmodSync, mkdtempSync, rmSync, statSync, 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, isWindows, 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,21 +49,77 @@ 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; beforeEach(() => { vi.useFakeTimers(); mockFetch.mockReset(); + recordActivityEvent.mockClear(); + resetPtyMock(); broadcaster = new SessionBroadcaster("3000"); }); @@ -262,6 +333,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)", () => { @@ -312,6 +966,41 @@ describe("TerminalManager.open — tmux target args (regression for #1714)", () const [, args] = mockPtySpawn.mock.calls[0]; expect(args).toEqual(["attach-session", "-t", "=ao-177"]); }); + + it("repairs node-pty spawn-helper when applicable and retries once after posix_spawnp failure", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "ao-mux-spawn-helper-")); + const helperPath = join(tempRoot, "spawn-helper"); + writeFileSync(helperPath, "#!/bin/sh\nexit 0\n"); + chmodSync(helperPath, 0o644); + process.env.AO_NODE_PTY_SPAWN_HELPER_PATH = helperPath; + + const pty = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + }; + + mockPtySpawn + .mockImplementationOnce(() => { + throw new Error("posix_spawnp failed."); + }) + .mockImplementationOnce(() => pty); + + try { + const mgr = new TerminalManager("/usr/bin/tmux"); + mgr.open("ao-177"); + + expect(mockPtySpawn).toHaveBeenCalledTimes(2); + if (!isWindows()) { + expect((statSync(helperPath).mode & 0o111) !== 0).toBe(true); + } + } finally { + delete process.env.AO_NODE_PTY_SPAWN_HELPER_PATH; + rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); describe("TerminalManager.open — re-attach skipped when tmux session is gone (regression for #1756)", () => { @@ -325,6 +1014,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()); @@ -355,6 +1045,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/__tests__/single-port-server.integration.test.ts b/packages/web/server/__tests__/single-port-server.integration.test.ts new file mode 100644 index 000000000..249f4f039 --- /dev/null +++ b/packages/web/server/__tests__/single-port-server.integration.test.ts @@ -0,0 +1,308 @@ +/** + * Integration tests for single-port-server — the opt-in HTTP + WebSocket + * proxy used when AO_PATH_BASED_MUX=1. + * + * These pin the four behaviours surfaced in review of PR #1757: + * 1. Hop-by-hop request headers are stripped before reaching the upstream. + * 2. X-Forwarded-For/-Proto/-Host are added so the upstream sees the client. + * 3. A non-101 response on the WS upgrade path is relayed, not left to hang. + * 4. shutdown() closes promptly even with a live connection open. + * + * The proxy is pointed at lightweight fake upstreams, so no tmux / Next.js is + * needed — these run everywhere, including CI on Windows. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { + createServer as createHttpServer, + request, + type Server, + type IncomingMessage, +} from "node:http"; +import { connect as netConnect, type AddressInfo } from "node:net"; +import { randomBytes } from "node:crypto"; +import { WebSocketServer, WebSocket } from "ws"; +import { createSinglePortServer, type SinglePortServer } from "../single-port-server.js"; + +// ============================================================================= +// Teardown registry — every server/proxy created by a test is closed here. +// ============================================================================= + +const closers: Array<() => void | Promise> = []; + +afterEach(async () => { + while (closers.length > 0) { + const close = closers.pop(); + if (close) await close(); + } +}); + +function portOf(server: Server): number { + const addr = server.address() as AddressInfo | null; + return addr ? addr.port : 0; +} + +/** A fake "Next.js" upstream that echoes the request it received as JSON. */ +async function startEchoUpstream(): Promise { + const server = createHttpServer((req, res) => { + let body = ""; + req.on("data", (c: Buffer) => (body += c.toString())); + req.on("end", () => { + // Set an explicit Content-Length (not chunked) so the raw-socket test + // can read the body without decoding chunk framing. + const payload = JSON.stringify({ + method: req.method, + url: req.url, + headers: req.headers, + body, + }); + res.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return portOf(server); +} + +/** A fake direct-terminal-ws upstream: real WS server on /mux that echoes. */ +async function startWsUpstream(): Promise { + const server = createHttpServer(); + const wss = new WebSocketServer({ server, path: "/mux" }); + wss.on("connection", (socket) => { + socket.on("message", (data) => socket.send(data)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => { + wss.close(); + return new Promise((r) => server.close(() => r())); + }); + return portOf(server); +} + +/** + * A fake upstream that answers a WS upgrade with a plain non-101 response — + * the case fix #3 exists for. + */ +async function startNon101Upstream(): Promise { + const server = createHttpServer(); + server.on("upgrade", (_req, socket) => { + socket.end( + "HTTP/1.1 503 Service Unavailable\r\n" + + "content-type: text/plain\r\n" + + "content-length: 11\r\n" + + "connection: close\r\n\r\n" + + "unavailable", + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + closers.push(() => new Promise((r) => server.close(() => r()))); + return portOf(server); +} + +/** Start the proxy in front of the given upstream ports. */ +async function startProxy(opts: { + nextInternalPort: number; + directTerminalPort: number; +}): Promise<{ proxy: SinglePortServer; port: number }> { + const proxy = createSinglePortServer({ + port: 0, + nextInternalPort: opts.nextInternalPort, + directTerminalPort: opts.directTerminalPort, + }); + await proxy.listen(); + closers.push(() => proxy.shutdown()); + return { proxy, port: portOf(proxy.server) }; +} + +/** + * Write a raw HTTP request and collect the full raw response. `closedByPeer` + * reports whether the server closed the connection on its own — used to prove + * the proxy honoured the client's `Connection: close` instead of forwarding + * the upstream's keep-alive. + */ +function rawHttpRequest( + port: number, + requestLines: string[], +): Promise<{ raw: string; closedByPeer: boolean }> { + return new Promise((resolve, reject) => { + const socket = netConnect({ port, host: "127.0.0.1" }, () => { + socket.write(requestLines.join("\r\n") + "\r\n\r\n"); + }); + let raw = ""; + let closedByPeer = false; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (raw += chunk)); + socket.on("error", reject); + socket.on("end", () => { + closedByPeer = true; + resolve({ raw, closedByPeer }); + }); + setTimeout(() => { + socket.destroy(); + resolve({ raw, closedByPeer }); + }, 2000); + }); +} + +// ============================================================================= +// HTTP forwarding — header sanitisation +// ============================================================================= + +describe("single-port HTTP forwarding", () => { + it("strips hop-by-hop headers and adds X-Forwarded-* before reaching upstream", async () => { + const nextPort = await startEchoUpstream(); + const { port } = await startProxy({ nextInternalPort: nextPort, directTerminalPort: 1 }); + + const { raw, closedByPeer } = await rawHttpRequest(port, [ + "GET /echo HTTP/1.1", + "Host: dashboard.example", + // "close" ends our client connection; "x-custom-hop" is named in + // Connection so it is hop-by-hop too — both must be stripped. + "Connection: close, X-Custom-Hop", + "X-Custom-Hop: should-be-stripped", + "Keep-Alive: timeout=99", + "X-Forwarded-For: 1.2.3.4", + ]); + + // The proxy must honour the client's Connection: close and not forward + // the upstream's keep-alive — otherwise the connection lingers. + expect(closedByPeer).toBe(true); + + const body = raw.slice(raw.indexOf("\r\n\r\n") + 4); + const seen = JSON.parse(body) as { headers: Record }; + + // The client's hop-by-hop headers must not leak to the upstream. + // X-Custom-Hop is listed in the client's Connection header, marking it + // hop-by-hop — its absence proves Connection-token parsing works. + expect(seen.headers["x-custom-hop"]).toBeUndefined(); + // The standard hop-by-hop Keep-Alive header is dropped. + expect(seen.headers["keep-alive"]).not.toBe("timeout=99"); + // The client's Connection value ("close") must not propagate. The + // proxy↔upstream hop has its own Connection header, managed by Node — + // that is correct and expected, so only assert the client's value is gone. + expect(String(seen.headers.connection ?? "")).not.toMatch(/close|x-custom-hop/); + + // X-Forwarded-For preserves the prior value and appends the proxy client. + expect(seen.headers["x-forwarded-for"]).toMatch(/^1\.2\.3\.4, /); + expect(seen.headers["x-forwarded-proto"]).toBe("http"); + expect(seen.headers["x-forwarded-host"]).toBe("dashboard.example"); + }); + + it("returns 502 when the Next.js upstream is unreachable", async () => { + // Point at a port nothing is listening on. + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: 1 }); + + const status = await new Promise((resolve, reject) => { + const req = request( + { host: "127.0.0.1", port, path: "/", method: "GET" }, + (res: IncomingMessage) => { + res.resume(); + resolve(res.statusCode ?? 0); + }, + ); + req.on("error", reject); + req.end(); + }); + + expect(status).toBe(502); + }); +}); + +// ============================================================================= +// WebSocket upgrade path +// ============================================================================= + +describe("single-port WebSocket upgrade", () => { + it("tunnels /ao-terminal-mux to the terminal upstream's /mux", async () => { + const wsPort = await startWsUpstream(); + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: wsPort }); + + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WS connect timeout")), 3000); + }); + + const echoed = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + ws.send("ping-through-proxy"); + setTimeout(() => reject(new Error("WS echo timeout")), 3000); + }); + + expect(echoed).toBe("ping-through-proxy"); + ws.close(); + }); + + it("relays a non-101 upstream response instead of hanging the client", async () => { + // Regression test for the WS-upgrade hang: before the `response` handler, + // a non-101 upstream answer left the client socket open until TCP timeout. + const badPort = await startNon101Upstream(); + const { port } = await startProxy({ nextInternalPort: 1, directTerminalPort: badPort }); + + const result = await new Promise<{ type: string; status?: number }>((resolve) => { + const req = request({ + host: "127.0.0.1", + port, + path: "/ao-terminal-mux", + headers: { + Connection: "Upgrade", + Upgrade: "websocket", + "Sec-WebSocket-Version": "13", + // Generated rather than hardcoded — a literal base64 nonce trips + // the repo's gitleaks pre-commit hook as a high-entropy string. + "Sec-WebSocket-Key": randomBytes(16).toString("base64"), + }, + }); + req.on("upgrade", (res) => resolve({ type: "upgrade", status: res.statusCode })); + req.on("response", (res) => { + res.resume(); + resolve({ type: "response", status: res.statusCode }); + }); + req.on("error", () => resolve({ type: "error" })); + // If the proxy hangs, none of the above fire and this surfaces it. + setTimeout(() => resolve({ type: "hang" }), 3000); + req.end(); + }); + + expect(result.type).toBe("response"); + expect(result.status).toBe(503); + }); +}); + +// ============================================================================= +// Shutdown +// ============================================================================= + +describe("single-port shutdown", () => { + it("closes promptly with a live WebSocket connection open", async () => { + // Regression test for shutdown hitting the force-exit timer: server.close() + // alone waits forever for the piped WS tunnel; closeAllConnections() fixes it. + const wsPort = await startWsUpstream(); + const { proxy, port } = await startProxy({ + nextInternalPort: 1, + directTerminalPort: wsPort, + }); + + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://127.0.0.1:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WS connect timeout")), 3000); + }); + + const start = Date.now(); + await proxy.shutdown(); + const elapsed = Date.now() - start; + + // Comfortably under the 5s force-exit timer the entrypoint arms. + expect(elapsed).toBeLessThan(1000); + + ws.close(); + }); +}); diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 99f028b1e..b54ff73c9 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -61,10 +61,15 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm // Manual upgrade routing — ws library doesn't support multiple WebSocketServer // instances with different `path` options on the same HTTP server. + // `/ao-terminal-mux` is accepted as an alias of `/mux` so deployments fronted + // by a path-routing reverse proxy (e.g. cloudflared, nginx) can forward the + // dashboard's path-based mux URL straight at this port without needing a + // path-rewrite rule. The dashboard's MuxProvider already constructs that + // path when accessed on a standard HTTPS port; see `packages/web/src/providers/MuxProvider.tsx`. server.on("upgrade", (request, socket, head) => { const pathname = new URL(request.url ?? "/", "ws://localhost").pathname; - if (pathname === "/mux" && muxWss) { + if ((pathname === "/mux" || pathname === "/ao-terminal-mux") && muxWss) { muxWss.handleUpgrade(request, socket, head, (ws) => { muxWss!.emit("connection", ws, request); }); diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 5515574df..9be199e2c 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -8,7 +8,22 @@ import { WebSocketServer, WebSocket } from "ws"; import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; import { type Socket, connect as netConnect } from "node:net"; +import { dirname, join } from "node:path"; +import { + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + getEnvDefaults, + getDashboardNotificationStorePath, + getNodePtyPrebuildsSubdir, + isWindows, + loadConfig, + normalizeDashboardNotificationLimit, + recordActivityEvent, + readDashboardNotificationsFromFile, + type DashboardNotificationRecord, +} from "@aoagents/ao-core"; import { findTmux, resolveTmuxSession, @@ -16,7 +31,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 +43,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 +53,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 +84,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; constructor(nextPort: string) { @@ -158,18 +182,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); @@ -181,8 +384,38 @@ export class SessionBroadcaster { // node-pty is an optionalDependency — load dynamically /* eslint-disable @typescript-eslint/consistent-type-imports -- node-pty is optional; static import would crash if missing */ type IPty = import("node-pty").IPty; -let ptySpawn: typeof import("node-pty").spawn | undefined; +type PtySpawn = typeof import("node-pty").spawn; +type PtySpawnOptions = Parameters[2]; +let ptySpawn: PtySpawn | undefined; /* eslint-enable @typescript-eslint/consistent-type-imports */ +const nodePtyRequire = createRequire(import.meta.url); + +export function resolveNodePtySpawnHelperPath(): string | null { + const override = process.env.AO_NODE_PTY_SPAWN_HELPER_PATH; + if (override) return override; + + try { + const packageJsonPath = nodePtyRequire.resolve("node-pty/package.json"); + return join(dirname(packageJsonPath), "prebuilds", getNodePtyPrebuildsSubdir(), "spawn-helper"); + } catch { + return null; + } +} + +function isPosixSpawnpFailure(err: unknown): boolean { + return err instanceof Error && err.message.includes("posix_spawnp"); +} + +function repairNodePtySpawnHelper(): string | null { + if (isWindows()) return null; + + const spawnHelperPath = resolveNodePtySpawnHelperPath(); + if (!spawnHelperPath || !fs.existsSync(spawnHelperPath)) return null; + + fs.chmodSync(spawnHelperPath, 0o755); + return spawnHelperPath; +} + try { const nodePty = await import("node-pty"); ptySpawn = nodePty.spawn; @@ -199,6 +432,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 @@ -232,6 +466,7 @@ const REATTACH_RESET_GRACE_MS = 5_000; export class TerminalManager { private terminals = new Map(); private TMUX: string; + private spawnHelperRepairAttempted = false; constructor(tmuxPath?: string) { const resolved = tmuxPath ?? findTmux(); @@ -245,6 +480,41 @@ export class TerminalManager { return projectId ? `${projectId}:${id}` : id; } + private spawnTmuxPty(args: string[], options: PtySpawnOptions): IPty { + if (!ptySpawn) { + throw new Error("node-pty not available"); + } + + try { + return ptySpawn(this.TMUX, args, options); + } catch (err) { + if (this.spawnHelperRepairAttempted || !isPosixSpawnpFailure(err)) { + throw err; + } + + this.spawnHelperRepairAttempted = true; + try { + const repairedPath = repairNodePtySpawnHelper(); + if (repairedPath) { + console.warn( + `[MuxServer] node-pty posix_spawnp failed; set executable bit on ${repairedPath} and retrying once.`, + ); + } else { + console.warn( + "[MuxServer] node-pty posix_spawnp failed; spawn-helper was not found, retrying once.", + ); + } + } catch (repairErr) { + const message = repairErr instanceof Error ? repairErr.message : String(repairErr); + console.warn( + `[MuxServer] node-pty posix_spawnp failed; chmod spawn-helper failed (${message}), retrying once.`, + ); + } + + return ptySpawn(this.TMUX, args, options); + } + } + /** * Open/attach to a terminal. If already open, just return. * If has subscribers but PTY crashed, re-attach. @@ -276,6 +546,7 @@ export class TerminalManager { buffer: [], bufferBytes: 0, reattachAttempts: 0, + ptyLostEmitted: false, }; this.terminals.set(key, terminal); } @@ -314,14 +585,10 @@ export class TerminalManager { TMPDIR: platformDefaults.TMPDIR, }; - if (!ptySpawn) { - throw new Error("node-pty not available"); - } - // Spawn PTY — use `=`-prefixed exact-match target so we never attach to // a session whose name happens to be a prefix of the requested id. const exactTmuxTarget = `=${tmuxSessionId}`; - const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], { + const pty = this.spawnTmuxPty(["attach-session", "-t", exactTmuxTarget], { name: "xterm-256color", cols: 80, rows: 24, @@ -346,6 +613,7 @@ export class TerminalManager { terminal.resetTimer = undefined; if (terminal.pty === pty) { terminal.reattachAttempts = 0; + terminal.ptyLostEmitted = false; } }, REATTACH_RESET_GRACE_MS); terminal.resetTimer.unref(); @@ -383,6 +651,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 @@ -392,15 +661,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); } @@ -423,14 +710,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); @@ -515,6 +851,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; @@ -586,8 +924,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(); @@ -637,9 +1003,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 */ @@ -651,7 +1016,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 })); } }); @@ -678,6 +1047,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); @@ -703,19 +1073,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. @@ -728,6 +1118,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(); } } @@ -759,7 +1165,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, @@ -841,7 +1254,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, @@ -853,7 +1272,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, @@ -900,9 +1326,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", @@ -917,11 +1377,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/server/single-port-server.ts b/packages/web/server/single-port-server.ts new file mode 100644 index 000000000..d344d0df6 --- /dev/null +++ b/packages/web/server/single-port-server.ts @@ -0,0 +1,378 @@ +/** + * Single-port server (opt-in) — a thin HTTP + WebSocket proxy that puts + * Next.js and the `/ao-terminal-mux` WebSocket upgrade on the same public + * port. Spawned by start-all.ts when AO_PATH_BASED_MUX=1, in front of a + * Next.js process that has shifted to an internal port. + * + * ┌──────────────────────┐ HTTP ┌──────────────────────┐ + * │ proxy on PORT │───────▶│ next start │ + * │ (this file) │ │ on NEXT_INTERNAL_PORT │ + * │ │ └──────────────────────┘ + * │ │ WS upgrade /ao-terminal-mux + * │ │───────▶┌──────────────────────┐ + * │ │ │ direct-terminal-ws │ + * │ │ │ on DIRECT_TERMINAL │ + * │ │ └──────────────────────┘ + * └──────────────────────┘ + * + * The default flow (AO_PATH_BASED_MUX unset) is unchanged: Next.js runs on + * PORT directly, direct-terminal-ws runs on DIRECT_TERMINAL_PORT, and the + * dashboard JS picks one of three URLs at connection time + * (see `packages/web/src/providers/MuxProvider.tsx`): + * + * 1. proxyWsPath (TERMINAL_WS_PATH) — explicit path-based routing + * 2. standard port (loc.port "" / 443 / 80) — `/ao-terminal-mux` on same host + * 3. fallback — direct connection to `:DIRECT_TERMINAL_PORT/mux` + * + * Path #1 and #3 require the operator to do something at the proxy layer + * (path rewrite or per-port routing). Path #2 only works if *something* is + * listening for the `/ao-terminal-mux` upgrade on the dashboard port. Until + * now, nothing was — Next.js doesn't handle upgrades, so the request fell + * through to its 404 handler. This server is that something. + * + * Use this when the reverse proxy in front of AO can only forward one + * hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at one + * `service:` URL with no path-based ingress). With this enabled, a single + * proxy rule pointing at PORT is sufficient — the WS path is multiplexed + * onto the same TCP port and demuxed here. + * + * `createSinglePortServer()` is exported so the proxy behaviour can be + * exercised in tests; the bottom-of-file entrypoint wires it to env vars and + * process signals when this file is run directly (as start-all.ts spawns it). + */ + +import { + createServer, + request as httpRequest, + type IncomingHttpHeaders, + type IncomingMessage, + type OutgoingHttpHeaders, + type Server, +} from "node:http"; +import type { Socket } from "node:net"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const MUX_PATH = "/ao-terminal-mux"; +const SHUTDOWN_TIMEOUT_MS = 5_000; + +/** + * Hop-by-hop headers (RFC 9110 §7.6.1) are meaningful only on a single + * transport connection and must not be forwarded by an intermediary. + * Forwarding e.g. a client's `Connection: close` would tear down the + * keep-alive socket to the upstream; a stray `Transfer-Encoding` would + * desync framing once the body is re-encoded. + */ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export interface SinglePortConfig { + /** Public-facing port this proxy listens on. */ + port: number; + /** Internal port Next.js has been shifted to. */ + nextInternalPort: number; + /** Port direct-terminal-ws listens on. */ + directTerminalPort: number; +} + +export interface SinglePortServer { + /** The underlying HTTP server — exposed for `.address()` in tests. */ + server: Server; + /** Begin listening on the configured port; resolves once bound. */ + listen(): Promise; + /** + * Stop listening and force-close every live connection, resolving once the + * server is fully closed. `server.close()` alone waits for keep-alive HTTP + * sockets and piped WS tunnels to drain on their own, which they never do. + */ + shutdown(): Promise; +} + +/** + * Build the header set for an upstream request: strip hop-by-hop headers + * (including any extra ones named in the client's `Connection` header) and + * append the standard `X-Forwarded-*` trio so the upstream still sees the + * real client IP / proto / host instead of `127.0.0.1`. + * + * On the WebSocket upgrade path, `keepUpgrade` retains `Connection` and + * `Upgrade` — the handshake is exactly the case where those headers are + * load-bearing rather than hop-by-hop noise. + */ +function buildUpstreamHeaders( + req: IncomingMessage, + opts: { keepUpgrade: boolean }, +): OutgoingHttpHeaders { + const drop = new Set(HOP_BY_HOP); + + const connection = req.headers.connection; + if (connection) { + const tokens = Array.isArray(connection) ? connection : connection.split(","); + for (const token of tokens) { + const name = token.trim().toLowerCase(); + if (name) drop.add(name); + } + } + if (opts.keepUpgrade) { + drop.delete("connection"); + drop.delete("upgrade"); + } + + const headers: OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (drop.has(key.toLowerCase())) continue; + headers[key] = value; + } + + // X-Forwarded-*: preserve anything an outer proxy already set, then add ours. + const clientIp = req.socket.remoteAddress ?? ""; + const priorFor = headers["x-forwarded-for"]; + headers["x-forwarded-for"] = priorFor + ? `${Array.isArray(priorFor) ? priorFor.join(", ") : String(priorFor)}, ${clientIp}` + : clientIp; + // This proxy itself terminates plain HTTP; an outer TLS proxy would have + // set x-forwarded-proto already, so only fill it in when absent. + if (headers["x-forwarded-proto"] === undefined) { + headers["x-forwarded-proto"] = "http"; + } + if (headers["x-forwarded-host"] === undefined && req.headers.host) { + headers["x-forwarded-host"] = req.headers.host; + } + return headers; +} + +/** + * Drop hop-by-hop headers from an upstream *response* before relaying it to + * the client. Without this the upstream's `Connection`/`Keep-Alive` would + * override the proxy↔client connection's own semantics — e.g. forwarding the + * upstream's `Connection: keep-alive` ignores a client that asked for `close`. + * Framing headers (`transfer-encoding`) are dropped here too so the client's + * `ServerResponse` re-derives framing for its own hop. + */ +function filterResponseHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders { + const out: OutgoingHttpHeaders = {}; + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (HOP_BY_HOP.has(key.toLowerCase())) continue; + out[key] = value; + } + return out; +} + +function tunnelUpgrade( + req: IncomingMessage, + clientSocket: Socket, + clientHead: Buffer, + target: { host: string; port: number; path: string }, +): void { + const proxyReq = httpRequest({ + host: target.host, + port: target.port, + method: "GET", + path: target.path, + headers: buildUpstreamHeaders(req, { keepUpgrade: true }), + }); + + proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => { + const lines = [ + `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}`, + ]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + + if (proxyHead.length > 0) clientSocket.write(proxyHead); + if (clientHead.length > 0) proxySocket.write(clientHead); + + clientSocket.pipe(proxySocket); + proxySocket.pipe(clientSocket); + + const teardown = (): void => { + clientSocket.destroy(); + proxySocket.destroy(); + }; + proxySocket.on("error", teardown); + proxySocket.on("close", teardown); + clientSocket.on("error", teardown); + clientSocket.on("close", teardown); + }); + + // Upstream answered the upgrade with an ordinary response (404, 502, + // mid-restart, path not in its allow-list, …) instead of a 101. Without + // this handler the `upgrade` event never fires and the client socket + // hangs until its TCP timeout. Relay the response and close cleanly. + proxyReq.on("response", (proxyRes) => { + if (clientSocket.writableEnded || clientSocket.destroyed) { + proxyRes.destroy(); + return; + } + const lines = [`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? ""}`]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + const lower = key.toLowerCase(); + // Body is delimited by connection close below, so drop framing headers. + if (HOP_BY_HOP.has(lower) || lower === "content-length") continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("connection: close"); + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + proxyRes.pipe(clientSocket); + proxyRes.on("end", () => clientSocket.end()); + }); + + proxyReq.on("error", (err) => { + console.error( + `[single-port] upstream upgrade error (${target.host}:${target.port}${target.path}): ${err.message}`, + ); + clientSocket.destroy(); + }); + + proxyReq.end(); +} + +/** + * Create the single-port proxy. The returned server is not yet listening — + * call `listen()`. + */ +export function createSinglePortServer(config: SinglePortConfig): SinglePortServer { + const { port, nextInternalPort, directTerminalPort } = config; + + // Sockets handed off via the 'upgrade' event are no longer tracked by the + // HTTP server, so `server.closeAllConnections()` does not destroy them and + // `server.close()`'s callback would wait on them forever. Track them here. + const upgradedSockets = new Set(); + + const server = createServer((req, res) => { + const proxyReq = httpRequest( + { + host: "127.0.0.1", + port: nextInternalPort, + method: req.method, + path: req.url, + headers: buildUpstreamHeaders(req, { keepUpgrade: false }), + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, filterResponseHeaders(proxyRes.headers)); + proxyRes.pipe(res); + }, + ); + + proxyReq.on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain" }); + } + res.end(`Bad gateway: ${err.message}`); + }); + + req.pipe(proxyReq); + }); + + server.on("upgrade", (req, socket, head) => { + const clientSocket = socket as Socket; + upgradedSockets.add(clientSocket); + clientSocket.once("close", () => upgradedSockets.delete(clientSocket)); + + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const target = + pathname === MUX_PATH + ? { host: "127.0.0.1", port: directTerminalPort, path: "/mux" } + : { host: "127.0.0.1", port: nextInternalPort, path: req.url ?? "/" }; + + tunnelUpgrade(req, clientSocket, head, target); + }); + + return { + server, + listen() { + return new Promise((resolve) => server.listen(port, () => resolve())); + }, + shutdown() { + return new Promise((resolve) => { + server.close(() => resolve()); + // closeAllConnections() handles keep-alive HTTP sockets; the upgraded + // WS tunnels are tracked separately and destroyed here. Destroying the + // client socket triggers tunnelUpgrade's teardown for the upstream side. + server.closeAllConnections(); + for (const socket of upgradedSockets) socket.destroy(); + }); + }, + }; +} + +/** Parse and validate the proxy config from env vars, exiting on bad input. */ +function configFromEnv(): SinglePortConfig { + const port = parseInt(process.env.PORT ?? "3000", 10); + const directTerminalPort = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10); + const nextInternalPort = parseInt(process.env.NEXT_INTERNAL_PORT ?? "0", 10); + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + console.error(`[single-port] Invalid PORT: ${process.env.PORT}`); + process.exit(1); + } + if ( + !Number.isInteger(directTerminalPort) || + directTerminalPort < 1 || + directTerminalPort > 65_535 + ) { + console.error( + `[single-port] Invalid DIRECT_TERMINAL_PORT: ${process.env.DIRECT_TERMINAL_PORT}`, + ); + process.exit(1); + } + if ( + !Number.isInteger(nextInternalPort) || + nextInternalPort < 1 || + nextInternalPort > 65_535 || + nextInternalPort === port + ) { + console.error( + `[single-port] Invalid NEXT_INTERNAL_PORT (must differ from PORT): ${process.env.NEXT_INTERNAL_PORT}`, + ); + process.exit(1); + } + return { port, nextInternalPort, directTerminalPort }; +} + +function main(): void { + const config = configFromEnv(); + const proxy = createSinglePortServer(config); + + void proxy.listen().then(() => { + console.log( + `[single-port] listening on ${config.port}; HTTP → 127.0.0.1:${config.nextInternalPort}; ${MUX_PATH} → 127.0.0.1:${config.directTerminalPort}/mux`, + ); + }); + + const onSignal = (): void => { + void proxy.shutdown().then(() => process.exit(0)); + setTimeout(() => process.exit(1), SHUTDOWN_TIMEOUT_MS).unref(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); +} + +/** True when this file was run directly (`node single-port-server.js`). */ +function isMainModule(): boolean { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + main(); +} diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index d54b11c33..971fbbf82 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -107,14 +107,32 @@ function resolveNextBin(): string { // Start Next.js production server const port = process.env["PORT"] || "3000"; +const pathBasedMux = process.env["AO_PATH_BASED_MUX"] === "1"; + +// When AO_PATH_BASED_MUX=1, single-port-server.js owns PORT and Next.js is +// shifted to PORT + 1000 (overridable via NEXT_INTERNAL_PORT). The proxy +// forwards HTTP to Next.js and tunnels `/ao-terminal-mux` WS upgrades to +// direct-terminal-ws. Default off — Next.js stays on PORT directly. +const NEXT_INTERNAL_OFFSET = 1000; +const nextPort = pathBasedMux + ? (process.env["NEXT_INTERNAL_PORT"] ?? String(parseInt(port, 10) + NEXT_INTERNAL_OFFSET)) + : port; + const nextBin = resolveNextBin(); if (isWindows() && nextBin !== "next") { // On Windows, run the JS entry point via the current node binary. // spawn() can't execute .js files directly on Windows. - spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]); + spawnProcess("next", process.execPath, [nextBin, "start", "-p", nextPort]); } else { - spawnProcess("next", nextBin, ["start", "-p", port]); + spawnProcess("next", nextBin, ["start", "-p", nextPort]); +} + +if (pathBasedMux) { + // Surface the internal port to the child so it doesn't have to re-derive + // the offset; pin it explicitly. + process.env["NEXT_INTERNAL_PORT"] = nextPort; + spawnProcess("single-port", process.execPath, [resolve(__dirname, "single-port-server.js")]); } // Start direct terminal WebSocket server (auto-restart on crash) 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 0a32bf0dc..3bb2eb496 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -674,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(); @@ -751,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(); 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__/services.test.ts b/packages/web/src/__tests__/services.test.ts index 8d9274670..427d7bbf9 100644 --- a/packages/web/src/__tests__/services.test.ts +++ b/packages/web/src/__tests__/services.test.ts @@ -10,6 +10,7 @@ const { tmuxPlugin, claudePlugin, codexPlugin, + grokPlugin, opencodePlugin, worktreePlugin, scmPlugin, @@ -44,6 +45,7 @@ const { tmuxPlugin: { manifest: { name: "tmux" } }, claudePlugin: { manifest: { name: "claude-code" } }, codexPlugin: { manifest: { name: "codex" } }, + grokPlugin: { manifest: { name: "grok" } }, opencodePlugin: { manifest: { name: "opencode" } }, worktreePlugin: { manifest: { name: "worktree" } }, scmPlugin: { manifest: { name: "github" } }, @@ -70,6 +72,7 @@ vi.mock("@aoagents/ao-core", () => ({ vi.mock("@aoagents/ao-plugin-runtime-tmux", () => ({ default: tmuxPlugin })); vi.mock("@aoagents/ao-plugin-agent-claude-code", () => ({ default: claudePlugin })); vi.mock("@aoagents/ao-plugin-agent-codex", () => ({ default: codexPlugin })); +vi.mock("@aoagents/ao-plugin-agent-grok", () => ({ default: grokPlugin })); vi.mock("@aoagents/ao-plugin-agent-opencode", () => ({ default: opencodePlugin })); vi.mock("@aoagents/ao-plugin-workspace-worktree", () => ({ default: worktreePlugin })); vi.mock("@aoagents/ao-plugin-scm-github", () => ({ default: scmPlugin })); @@ -120,6 +123,14 @@ describe("services", () => { expect(mockRegister).toHaveBeenCalledWith(codexPlugin); }); + it("registers the Grok agent plugin with web services", async () => { + const { getServices } = await import("../lib/services"); + + await getServices(); + + expect(mockRegister).toHaveBeenCalledWith(grokPlugin); + }); + it("caches initialized services across repeated calls", async () => { const { getServices } = await import("../lib/services"); 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 54890fa85..40e6c3972 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,9 +1,12 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt } from "@aoagents/ao-core"; +import { generateOrchestratorPrompt, recordActivityEvent } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; -function classifySpawnError(projectId: string, error: unknown): { +function classifySpawnError( + projectId: string, + error: unknown, +): { status: number; payload: Record; } { @@ -61,6 +64,14 @@ export async function POST(request: NextRequest) { ? 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( { orchestrator: { diff --git a/packages/web/src/app/api/projects/[id]/route.ts b/packages/web/src/app/api/projects/[id]/route.ts index c98964402..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; @@ -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 2bafa5212..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"; @@ -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/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/version/route.ts b/packages/web/src/app/api/version/route.ts index 768820ed8..2613a7d0a 100644 --- a/packages/web/src/app/api/version/route.ts +++ b/packages/web/src/app/api/version/route.ts @@ -13,7 +13,7 @@ import { NextResponse } from "next/server"; import { getInstalledAoVersion, - isVersionOutdated, + isVersionOutdatedForChannel, loadGlobalConfig, readUpdateCheckCacheRaw, type UpdateChannel, @@ -53,7 +53,8 @@ export async function GET() { const latest = cache?.latestVersion && cacheMatchesChannel ? cache.latestVersion : null; // Git installs cache `latestVersion: "origin/main"` (a ref, not a semver), - // so `isVersionOutdated(current, "origin/main")` would always return false. + // so `isVersionOutdatedForChannel(current, "origin/main", channel)` would + // always return false. // The CLI works around this by trusting the precomputed `cached.isOutdated` // for git installs — mirror that here so the dashboard banner actually // appears when a git-installed user is behind origin/main. @@ -62,7 +63,7 @@ export async function GET() { isOutdated = cache?.installMethod === "git" ? cache.isOutdated === true - : isVersionOutdated(current, latest); + : isVersionOutdatedForChannel(current, latest, channel); } const body: VersionResponse = { 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 14f6fcf83..1e0d69f91 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; @@ -1198,6 +1206,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .dashboard-app-header__project { font-size: 12px; font-weight: 400; + line-height: 1; color: var(--color-text-secondary); } @@ -1211,6 +1220,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; @@ -1303,6 +1347,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 999px; font-size: 11px; font-weight: 500; + line-height: 1; white-space: nowrap; color: var(--color-text-secondary); } @@ -1329,6 +1374,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { height: 6px; border-radius: 50%; flex-shrink: 0; + background: var(--color-text-muted); +} +.topbar-status-pill--active .topbar-status-pill__dot { + background: var(--color-status-working); +} +.topbar-status-pill--ready .topbar-status-pill__dot { + background: var(--color-status-ready); +} +.topbar-status-pill--idle .topbar-status-pill__dot { + background: var(--color-text-tertiary); +} +.topbar-status-pill--waiting-for-input .topbar-status-pill__dot { + background: var(--color-status-attention); +} +.topbar-status-pill--blocked .topbar-status-pill__dot, +.topbar-status-pill--exited .topbar-status-pill__dot { + background: var(--color-status-error); } .topbar-status-pill__label { color: inherit; @@ -1420,6 +1482,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 { @@ -1434,6 +1902,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 { @@ -1502,7 +1983,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; @@ -2196,15 +2676,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-detail-mode-badge { display: inline-flex; align-items: center; + gap: 4px; padding: 2px 8px; border: 1px solid var(--color-accent-amber-border); background: var(--color-accent-amber-dim); color: var(--color-accent-amber); font-size: 10px; font-weight: 600; + line-height: 1; letter-spacing: 0.05em; } +.session-detail-mode-badge--neutral { + border-color: var(--color-border-default); + background: var(--color-bg-subtle); + color: var(--color-text-secondary); +} + .session-detail-link-pill { display: inline-flex; align-items: center; @@ -2946,6 +3434,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; @@ -3023,6 +3518,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; @@ -5114,6 +5630,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 { @@ -5245,8 +5762,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; @@ -7572,7 +8578,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { margin-right: 2px; } - /* Stack: session name on top, status+XDA row below */ + /* Stack: session name on top, status row below */ .terminal-chrome-identity { flex-direction: column; align-items: flex-start; @@ -7621,11 +8627,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { so this inner .topbar-project-line only exists to group them on mobile. */ .topbar-project-line { display: inline-flex; - align-items: baseline; + align-items: center; gap: 6px; min-width: 0; } +.topbar-identity-sep { + color: var(--color-text-tertiary); + font-size: 12px; + line-height: 1; +} + /* topbar-mobile-only is the inverse of topbar-desktop-only; hidden on desktop, shown below the mobile breakpoint. */ .topbar-mobile-only { @@ -7694,6 +8706,14 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 1px 5px; gap: 3px; } + .topbar-session-pills .topbar-fleet-pills { + gap: 3px; + padding-left: 6px; + margin-left: 3px; + } + .topbar-session-pills .topbar-fleet-pills__label { + font-size: 9px; + } /* Hide zone pill labels on mobile — count + color is enough */ .topbar-session-pills .topbar-zone-pill__label { display: none; @@ -7702,6 +8722,21 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { /* Orchestrator agent-zone pills — compact topbar variant. Replaces the stacked status strip that used to sit above the terminal. */ +.topbar-fleet-pills { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding-left: 10px; + border-left: 1px solid var(--color-border-default); +} +.topbar-fleet-pills__label { + font-size: 11px; + font-weight: 600; + line-height: 1; + color: var(--color-text-muted); + white-space: nowrap; +} .topbar-zone-pill { display: inline-flex; align-items: center; @@ -7710,6 +8745,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 999px; font-size: 11px; font-weight: 500; + line-height: 1; white-space: nowrap; color: var(--color-text-secondary); } diff --git a/packages/web/src/app/projects/[projectId]/loading.tsx b/packages/web/src/app/projects/[projectId]/loading.tsx index ffeee105c..7df4da536 100644 --- a/packages/web/src/app/projects/[projectId]/loading.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.tsx @@ -1,40 +1,46 @@ 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..6cdb15ce8 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", @@ -53,5 +80,7 @@ describe("ProjectPage", () => { expect(screen.getByText("This project's config failed to load")).toBeInTheDocument(); expect(screen.getByText("Local config failed validation")).toBeInTheDocument(); expect(screen.queryByTestId("dashboard")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + expect(screen.queryByRole("link", { name: "Edit settings" })).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/projects/[projectId]/page.tsx b/packages/web/src/app/projects/[projectId]/page.tsx index 39d317391..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 ( -
+
{ ).toBeInTheDocument(); expect(screen.getByText("Local config failed validation")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + expect(screen.queryByRole("link", { name: "Edit settings" })).not.toBeInTheDocument(); }); }); 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 6fa1990e9..b7e7b87d1 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -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 () => { 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/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index c270831f1..dd8dc71ca 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -24,10 +24,11 @@ import { ToastProvider, useToast } from "./Toast"; import { ConnectionBar } from "./ConnectionBar"; import { UpdateBanner } from "./UpdateBanner"; import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; +import { DashboardNotificationButton } from "./DashboardNotificationButton"; import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext"; import { ProjectSidebar } from "./ProjectSidebar"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; +import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes"; import { BottomSheet } from "./BottomSheet"; interface DashboardProps { @@ -125,7 +126,7 @@ function DoneCard({ ) : null} {formatRelativeTimeCompact(session.lastActivityAt)} - {canRestore && !isMerged ? ( + {canRestore ? ( +
+ + ); +} + +function pruneReadIds( + readIds: Set, + notifications: DashboardNotificationRecord[], +): Set { + const available = new Set(notifications.map(notificationKey)); + return new Set([...readIds].filter((id) => available.has(id))); +} + +export function DashboardNotificationButton() { + const mux = useMuxOptional(); + const notifications = mux?.notifications ?? []; + const error = mux?.notificationError ?? null; + const [open, setOpen] = useState(false); + const [view, setView] = useState("all"); + const [readIds, setReadIds] = useState>(() => readStoredReadIds()); + const rootRef = useRef(null); + const unreadCount = useMemo( + () => + notifications.filter((notification) => !readIds.has(notificationKey(notification))).length, + [notifications, readIds], + ); + const allRead = notifications.length > 0 && unreadCount === 0; + const visibleNotifications = useMemo(() => { + const filtered = + view === "unread" + ? notifications.filter((notification) => !readIds.has(notificationKey(notification))) + : notifications; + return [...filtered].reverse(); + }, [notifications, readIds, view]); + + useEffect(() => { + if (notifications.length === 0) return; + setReadIds((current) => { + const pruned = pruneReadIds(current, notifications); + if (pruned.size === current.size) return current; + writeStoredReadIds(pruned); + return pruned; + }); + }, [notifications]); + + const markRead = (notification: DashboardNotificationRecord) => { + setReadIds((current) => { + const key = notificationKey(notification); + if (current.has(key)) return current; + const next = new Set(current); + next.add(key); + writeStoredReadIds(next); + return next; + }); + }; + + const markUnread = (notification: DashboardNotificationRecord) => { + setReadIds((current) => { + const key = notificationKey(notification); + if (!current.has(key)) return current; + const next = new Set(current); + next.delete(key); + writeStoredReadIds(next); + return next; + }); + }; + + const markAllRead = () => { + setReadIds((current) => { + const next = new Set(current); + for (const notification of notifications) { + next.add(notificationKey(notification)); + } + writeStoredReadIds(next); + return next; + }); + }; + + const markAllUnread = () => { + setReadIds((current) => { + const next = new Set(current); + for (const notification of notifications) { + next.delete(notificationKey(notification)); + } + writeStoredReadIds(next); + return next; + }); + }; + + useEffect(() => { + if (!open) return; + + const onPointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + return ( +
+ + + {open ? ( +
+
+
Notifications
+
+ +
+
+
+ + +
+ {error ?
{error}
: null} + {visibleNotifications.length > 0 ? ( +
    + {visibleNotifications.map((notification) => ( + markRead(notification)} + onMarkUnread={() => markUnread(notification)} + /> + ))} +
+ ) : ( +
+ {view === "unread" ? "No unread notifications" : "No notifications yet"} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/packages/web/src/components/DegradedProjectState.tsx b/packages/web/src/components/DegradedProjectState.tsx index c1b147f63..c02c734a5 100644 --- a/packages/web/src/components/DegradedProjectState.tsx +++ b/packages/web/src/components/DegradedProjectState.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { projectDashboardPath } from "@/lib/routes"; + import { RepairDegradedProjectButton } from "./RepairDegradedProjectButton"; interface DegradedProjectStateProps { @@ -61,16 +61,10 @@ export function DegradedProjectState({
- Back to project - - - Open dashboard view + Back to dashboard
diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index c6b29a7f6..24c21691f 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -9,7 +9,7 @@ import { getAttentionLevel, type DashboardSession } from "@/lib/types"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { getSessionTitle, humanizeBranch } from "@/lib/format"; import { usePopoverClamp } from "@/hooks/usePopoverClamp"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; +import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes"; import { ThemeToggle } from "./ThemeToggle"; import { AddProjectModal } from "./AddProjectModal"; import { ProjectSettingsModal } from "./ProjectSettingsModal"; @@ -846,6 +846,31 @@ function ProjectSidebarInner({ ) : null} + {!isDegraded ? ( + { + e.stopPropagation(); + onMobileClose?.(); + }} + className="project-sidebar__proj-action" + aria-label={`Open ${project.name} reviews`} + title="Reviews" + > + + + + + + ) : null} + {!isDegraded && orchestratorLink && ( = { + queued: "Review work requested but not executing yet.", + reviewing: "A reviewer is reading a snapshot.", + triage: "Findings need a human decision.", + waiting: "Feedback is with the coding worker.", + clean: "No open AO findings remain.", + failed: "Reviewer runs that need retry or inspection.", + outdated: "Runs superseded by newer worker commits.", +}; + +const SUPERSEDABLE_REVIEW_STATUSES = new Set([ + "queued", + "needs_triage", + "sent_to_agent", + "waiting_update", + "clean", +]); + +function formatRelativeTime(value: string): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return value; + const diffMs = Date.now() - timestamp; + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return `${Math.floor(diffHours / 24)}d ago`; +} + +function formatStatus(value: string): string { + return value.replaceAll("_", " "); +} + +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 pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function canSendFeedbackToWorker(run: DashboardReviewRun): boolean { + if (!run.workerHasRuntime) return false; + if (run.workerActivity === "exited") return false; + return run.workerRuntimeState !== "missing" && run.workerRuntimeState !== "exited"; +} + +function getWorkerAvailabilityLabel(run: DashboardReviewRun): string { + if (!run.workerHasRuntime) return "no runtime"; + if (run.workerActivity === "exited") return "exited"; + if (run.workerRuntimeState === "missing") return "runtime missing"; + if (run.workerRuntimeState === "exited") return "runtime exited"; + return run.workerActivity ?? run.workerStatus ?? "worker"; +} + +function mergeOrchestrators( + current: DashboardOrchestratorLink[], + incoming: DashboardOrchestratorLink[], +): DashboardOrchestratorLink[] { + const merged = new Map(current.map((orchestrator) => [orchestrator.projectId, orchestrator])); + for (const orchestrator of incoming) { + merged.set(orchestrator.projectId, orchestrator); + } + return Array.from(merged.values()); +} + +function markSupersededReviewRuns( + current: DashboardReviewRun[], + nextRun: DashboardReviewRun, +): DashboardReviewRun[] { + if (!nextRun.targetSha) return current; + + return current.map((run) => { + if (run.linkedSessionId !== nextRun.linkedSessionId) return run; + if (run.id === nextRun.id) return run; + if (!run.targetSha || run.targetSha === nextRun.targetSha) return run; + if (!SUPERSEDABLE_REVIEW_STATUSES.has(run.status)) return run; + return { ...run, status: "outdated" }; + }); +} + +function ReviewDashboardInner({ + runs = EMPTY_RUNS, + sidebarSessions = EMPTY_SESSIONS, + orchestrators = EMPTY_ORCHESTRATORS, + workerOptions = EMPTY_WORKERS, + projectId, + projectName, + projects, + dashboardLoadError, +}: ReviewDashboardProps) { + const { showToast } = useToast(); + const router = useRouter(); + const menuRef = useRef(null); + const [reviewRuns, setReviewRuns] = useState(runs); + const [activeOrchestrators, setActiveOrchestrators] = + useState(orchestrators); + const [requestingSessionId, setRequestingSessionId] = useState(null); + const [executingRunIds, setExecutingRunIds] = useState>(() => new Set()); + const [sendingRunIds, setSendingRunIds] = useState>(() => new Set()); + const [restoringOrchestratorId, setRestoringOrchestratorId] = useState(null); + const [newReviewMenuOpen, setNewReviewMenuOpen] = useState(false); + const [reviewDetails, setReviewDetails] = useState(null); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const isMobile = useMediaQuery(MOBILE_BREAKPOINT); + + useEffect(() => { + setReviewRuns(runs); + }, [runs]); + + useEffect(() => { + setActiveOrchestrators((current) => mergeOrchestrators(current, orchestrators)); + }, [orchestrators]); + + useEffect(() => { + if (!newReviewMenuOpen) return; + const handlePointer = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setNewReviewMenuOpen(false); + } + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setNewReviewMenuOpen(false); + } + }; + document.addEventListener("mousedown", handlePointer); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handlePointer); + document.removeEventListener("keydown", handleKey); + }; + }, [newReviewMenuOpen]); + + useEffect(() => { + if (!reviewDetails) return; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setReviewDetails(null); + } + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [reviewDetails]); + + const grouped = useMemo(() => { + const columns: Record = { + queued: [], + reviewing: [], + triage: [], + waiting: [], + clean: [], + failed: [], + outdated: [], + }; + for (const run of reviewRuns) { + columns[getReviewBoardColumn(run)].push(run); + } + return columns; + }, [reviewRuns]); + + const allProjectsView = !projectId; + const openFindingCount = reviewRuns.reduce((sum, run) => sum + run.openFindingCount, 0); + const activeRunCount = reviewRuns.filter((run) => + ["queued", "preparing", "running", "needs_triage", "sent_to_agent", "waiting_update"].includes( + run.status, + ), + ).length; + const currentProjectOrchestrator = projectId + ? (activeOrchestrators.find((orchestrator) => orchestrator.projectId === projectId) ?? null) + : null; + const orchestratorHref = currentProjectOrchestrator + ? projectSessionPath(currentProjectOrchestrator.projectId, currentProjectOrchestrator.id) + : null; + const visibleWorkerOptions = projectId + ? workerOptions.filter((worker) => worker.projectId === projectId) + : workerOptions; + const codingHref = projectId ? projectDashboardPath(projectId) : "/?project=all"; + const reviewHref = projectReviewPath(projectId); + const headerProjectLabel = projectName ?? (allProjectsView ? "All projects" : "Reviews"); + + const handleToggleSidebar = () => { + if (isMobile) { + setMobileMenuOpen((current) => !current); + } else { + setSidebarCollapsed((current) => !current); + } + }; + + const handleRequestReview = async (worker: ReviewWorkerOption) => { + if (requestingSessionId) return; + setRequestingSessionId(worker.id); + try { + const response = await fetch("/api/reviews", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: worker.id }), + }); + const data = (await response.json().catch(() => null)) as { + run?: DashboardReviewRun; + error?: string; + } | null; + if (!response.ok || !data?.run) { + throw new Error(data?.error ?? "Failed to request review"); + } + + const nextRun: DashboardReviewRun = { + ...data.run, + projectName: worker.projectName, + workerTitle: worker.title, + workerBranch: worker.branch, + workerPrUrl: worker.prUrl ?? data.run.prUrl ?? null, + workerStatus: worker.status, + workerActivity: worker.activity, + workerRuntimeState: worker.runtimeState, + workerHasRuntime: worker.hasRuntime, + }; + setReviewRuns((current) => [ + nextRun, + ...markSupersededReviewRuns( + current.filter((run) => run.id !== nextRun.id), + nextRun, + ), + ]); + setNewReviewMenuOpen(false); + showToast("Review run requested", "success"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to request review"; + showToast(`Review failed: ${message}`, "error"); + } finally { + setRequestingSessionId(null); + } + }; + + const handleExecuteRun = async (run: DashboardReviewRun) => { + if (executingRunIds.has(run.id)) return; + setExecutingRunIds((current) => { + const next = new Set(current); + next.add(run.id); + return next; + }); + setReviewRuns((current) => + current.map((entry) => (entry.id === run.id ? { ...entry, status: "running" } : entry)), + ); + try { + const response = await fetch("/api/reviews/execute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId: run.projectId, + runId: run.id, + force: run.status === "failed", + }), + }); + const data = (await response.json().catch(() => null)) as { + run?: DashboardReviewRun; + error?: string; + } | null; + if (!response.ok || !data?.run) { + throw new Error(data?.error ?? "Failed to execute review"); + } + + setReviewRuns((current) => + current.map((entry) => + entry.id === run.id + ? { + ...entry, + ...data.run, + projectName: entry.projectName, + workerTitle: entry.workerTitle, + workerBranch: entry.workerBranch, + workerPrUrl: entry.workerPrUrl, + workerStatus: entry.workerStatus, + workerActivity: entry.workerActivity, + workerRuntimeState: entry.workerRuntimeState, + workerHasRuntime: entry.workerHasRuntime, + } + : entry, + ), + ); + if (data.run.status === "failed") { + showToast( + `Review failed: ${data.run.terminationReason ?? "Reviewer execution failed"}`, + "error", + ); + return; + } + showToast( + data.run.openFindingCount > 0 ? "Review findings ready" : "Review completed clean", + "success", + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to execute review"; + setReviewRuns((current) => + current.map((entry) => (entry.id === run.id ? { ...entry, status: "failed" } : entry)), + ); + showToast(`Review failed: ${message}`, "error"); + } finally { + setExecutingRunIds((current) => { + const next = new Set(current); + next.delete(run.id); + return next; + }); + } + }; + + const mergeRunUpdate = (run: DashboardReviewRun, nextRun: DashboardReviewRun) => ({ + ...run, + ...nextRun, + projectName: run.projectName, + workerTitle: run.workerTitle, + workerBranch: run.workerBranch, + workerPrUrl: run.workerPrUrl, + workerStatus: run.workerStatus, + workerActivity: run.workerActivity, + workerRuntimeState: run.workerRuntimeState, + workerHasRuntime: run.workerHasRuntime, + }); + + const handleSendFeedback = async (run: DashboardReviewRun) => { + if (sendingRunIds.has(run.id) || run.openFindingCount === 0) return; + setSendingRunIds((current) => { + const next = new Set(current); + next.add(run.id); + return next; + }); + try { + const response = await fetch("/api/reviews/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: run.projectId, runId: run.id }), + }); + const data = (await response.json().catch(() => null)) as { + run?: DashboardReviewRun; + sentFindingCount?: number; + error?: string; + } | null; + if (!response.ok || !data?.run) { + throw new Error(data?.error ?? "Failed to send review findings"); + } + + setReviewRuns((current) => + current.map((entry) => + entry.id === run.id ? mergeRunUpdate(entry, data.run as DashboardReviewRun) : entry, + ), + ); + setReviewDetails((current) => { + if (!current || current.run.id !== run.id) return current; + const sentAt = new Date().toISOString(); + return { + ...current, + run: mergeRunUpdate(current.run, data.run as DashboardReviewRun), + findings: current.findings.map((finding) => + finding.status === "open" + ? { ...finding, status: "sent_to_agent", sentToAgentAt: sentAt } + : finding, + ), + }; + }); + showToast( + `Sent ${pluralize(data.sentFindingCount ?? 0, "finding")} to ${run.linkedSessionId}`, + "success", + ); + router.push( + projectSessionHashPath(run.projectId, run.linkedSessionId, "#session-terminal-section"), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to send review findings"; + showToast(`Feedback failed: ${message}`, "error"); + } finally { + setSendingRunIds((current) => { + const next = new Set(current); + next.delete(run.id); + return next; + }); + } + }; + + const handleRestoreOrchestrator = async (orchestrator: DashboardOrchestratorLink) => { + if (restoringOrchestratorId) return; + setRestoringOrchestratorId(orchestrator.id); + try { + const response = await fetch(`/api/sessions/${encodeURIComponent(orchestrator.id)}/restore`, { + method: "POST", + }); + const data = (await response.json().catch(() => null)) as { + error?: string; + session?: DashboardSession; + } | null; + if (!response.ok) { + throw new Error(data?.error ?? "Failed to restore orchestrator"); + } + + setActiveOrchestrators((current) => + current.map((entry) => + entry.id === orchestrator.id + ? { + ...entry, + status: data?.session?.status ?? entry.status, + activity: data?.session?.activity ?? entry.activity, + runtimeState: data?.session?.lifecycle?.runtimeState ?? "alive", + hasRuntime: true, + isTerminal: false, + isRestorable: false, + } + : entry, + ), + ); + showToast("Orchestrator restored", "success"); + router.push(projectSessionPath(orchestrator.projectId, orchestrator.id)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to restore orchestrator"; + showToast(`Restore failed: ${message}`, "error"); + } finally { + setRestoringOrchestratorId(null); + } + }; + + const handleOpenReviewDetails = async (run: DashboardReviewRun) => { + setReviewDetails({ run, findings: [], loading: true, error: null }); + try { + const params = new URLSearchParams({ projectId: run.projectId, runId: run.id }); + const response = await fetch(`/api/reviews/findings?${params.toString()}`); + const data = (await response.json().catch(() => null)) as { + findings?: CodeReviewFinding[]; + error?: string; + } | null; + if (!response.ok || !data?.findings) { + throw new Error(data?.error ?? "Failed to load review findings"); + } + + setReviewDetails((current) => + current?.run.id === run.id + ? { ...current, findings: data.findings ?? [], loading: false, error: null } + : current, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load review findings"; + setReviewDetails((current) => + current?.run.id === run.id + ? { ...current, findings: [], loading: false, error: message } + : current, + ); + } + }; + + return ( + +
+
+ +
+
+
+ +
+
+ setSidebarCollapsed((current) => !current)} + onMobileClose={() => setMobileMenuOpen(false)} + /> +
+ {mobileMenuOpen ? ( +
setMobileMenuOpen(false)} /> + ) : null} + +
+
+
+

+ {projectName ? `${projectName} Reviews` : "Reviews"} +

+

+ AO-local reviewer runs, findings, and worker handoffs + {allProjectsView ? " across all projects" : " for this project"}. +

+
+
+ + + +
+
+ + {dashboardLoadError ? ( +
+ {dashboardLoadError} +
+ ) : null} + + {reviewRuns.length === 0 ? ( +
+
No review runs yet
+

+ Reviewer runs will appear here after a worker is ready for review or after a + manual review is requested. +

+ + Back to coding dashboard + +
+ ) : ( +
+
+ {REVIEW_BOARD_COLUMNS.map((column) => ( + + ))} +
+
+ )} +
+
+ {reviewDetails ? ( + setReviewDetails(null)} + onOpenWorker={() => setReviewDetails(null)} + isSending={sendingRunIds.has(reviewDetails.run.id)} + onSendFeedback={handleSendFeedback} + /> + ) : null} +
+ + ); +} + +export function ReviewDashboard(props: ReviewDashboardProps) { + return ( + + + + ); +} + +function ReviewMetric({ label, value, meta }: { label: string; value: number; meta: string }) { + return ( +
+ {value} + {label} + {meta} +
+ ); +} + +function ReviewColumn({ + column, + runs, + allProjectsView, + executingRunIds, + sendingRunIds, + onOpenDetails, + onExecute, + onSendFeedback, +}: { + column: ReviewBoardColumn; + runs: DashboardReviewRun[]; + allProjectsView: boolean; + executingRunIds: Set; + sendingRunIds: Set; + onOpenDetails: (run: DashboardReviewRun) => void; + onExecute: (run: DashboardReviewRun) => void; + onSendFeedback: (run: DashboardReviewRun) => void; +}) { + return ( +
+
+
+
+ {REVIEW_COLUMN_LABELS[column]} + {runs.length} +
+

{COLUMN_HINTS[column]}

+
+ +
+ {runs.length > 0 ? ( +
+ {runs.map((run) => ( + + ))} +
+ ) : null} +
+
+ ); +} + +function ReviewCard({ + run, + allProjectsView, + isExecuting, + isSending, + onOpenDetails, + onExecute, + onSendFeedback, +}: { + run: DashboardReviewRun; + allProjectsView: boolean; + isExecuting: boolean; + isSending: boolean; + onOpenDetails: (run: DashboardReviewRun) => void; + onExecute: (run: DashboardReviewRun) => void; + onSendFeedback: (run: DashboardReviewRun) => void; +}) { + const workerHref = projectDashboardSessionPath(run.projectId, run.linkedSessionId); + const title = run.workerTitle ?? run.linkedSessionId; + const status = formatStatus(run.status); + const totalFindingLabel = pluralize(run.findingCount, "finding"); + const secondaryText = + run.summary ?? + (run.status === "clean" + ? "Reviewer completed without open AO findings." + : `Review requested for ${run.linkedSessionId}.`); + const truthLine = `${status} · ${totalFindingLabel}${ + run.dismissedFindingCount > 0 ? ` · ${pluralize(run.dismissedFindingCount, "dismissed")}` : "" + }${run.sentFindingCount > 0 ? ` · ${pluralize(run.sentFindingCount, "sent")}` : ""} · worker ${getWorkerAvailabilityLabel(run)}`; + const canExecute = isExecuting || run.status === "queued" || run.status === "failed"; + const feedbackAvailable = canSendFeedbackToWorker(run); + const dotClass = + run.status === "running" || run.status === "preparing" + ? "card__adot--working" + : run.status === "clean" + ? "card__adot--ready" + : run.status === "needs_triage" || run.status === "failed" || run.status === "cancelled" + ? "card__adot--waiting" + : run.status === "sent_to_agent" || run.status === "waiting_update" + ? "card__adot--ready" + : "card__adot--idle"; + + return ( +
+
+ + + {allProjectsView ? `${run.projectName} · ` : ""} + {run.reviewerSessionId} + +
+ +
+ +
+
+

{title}

+
+ +
+ +
+

{secondaryText}

+
+ +
+

+ {truthLine} +

+
+ + {run.openFindingCount > 0 ? ( +
+
+ + + + + +
+
+ ) : null} + +
+ + {status} · updated {formatRelativeTime(run.updatedAt)} + +
+ {canExecute ? ( + + ) : null} + + Worker + + {feedbackAvailable ? ( + + ) : ( + + {getWorkerAvailabilityLabel(run)} + + )} +
+
+
+
+ ); +} + +function ReviewDetailsDrawer({ + state, + onClose, + onOpenWorker, + isSending, + onSendFeedback, +}: { + state: ReviewDetailsState; + onClose: () => void; + onOpenWorker: () => void; + isSending: boolean; + onSendFeedback: (run: DashboardReviewRun) => void; +}) { + const { run, findings, loading, error } = state; + const workerHref = projectDashboardSessionPath(run.projectId, run.linkedSessionId); + const feedbackHref = projectSessionHashPath( + run.projectId, + run.linkedSessionId, + "#session-terminal-section", + ); + const openFindings = findings.filter((finding) => finding.status === "open"); + const feedbackAvailable = canSendFeedbackToWorker(run); + + return ( + <> +
+ + + ); +} diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 063dfda70..9132075fb 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -33,6 +33,7 @@ interface SessionCardProps { onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; onRestore?: (sessionId: string) => void; + onReview?: (sessionId: string) => Promise | void; } /** @@ -126,12 +127,20 @@ function getDoneStatusInfo(session: DashboardSession): { }; } -function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) { +function SessionCardView({ + session, + onSend, + onKill, + onMerge, + onRestore, + onReview, +}: SessionCardProps) { const [expanded, setExpanded] = useState(false); const [sendingAction, setSendingAction] = useState(null); const [failedAction, setFailedAction] = useState(null); const [sendingQuickReply, setSendingQuickReply] = useState(null); const [sentQuickReply, setSentQuickReply] = useState(null); + const [requestingReview, setRequestingReview] = useState(false); const [killConfirming, setKillConfirming] = useState(false); const [replyText, setReplyText] = useState(""); const actionTimerRef = useRef | null>(null); @@ -259,6 +268,18 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio onKill?.(session.id); }; + const handleReviewClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (requestingReview || !onReview) return; + + setRequestingReview(true); + try { + await Promise.resolve(onReview(session.id)); + } finally { + setRequestingReview(false); + } + }; + /* ── Done card variant ──────────────────────────────────────────── */ if (isDone) { const statusInfo = getDoneStatusInfo(session); @@ -827,44 +848,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio {isReadyToMerge && pr ? ( - - ) : ( - !isTerminal && ( - + ) : null} + +
+ ) : ( + !isTerminal && ( +
+ {onReview ? ( + + ) : null} + +
) )}
@@ -892,7 +959,8 @@ function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps prev.onSend === next.onSend && prev.onKill === next.onKill && prev.onMerge === next.onMerge && - prev.onRestore === next.onRestore + prev.onRestore === next.onRestore && + prev.onReview === next.onReview ); } @@ -918,8 +986,8 @@ interface Alert { type: "ci" | "changes" | "review" | "conflict" | "comment"; icon: React.ReactNode; label: string; - url: string; count?: number; + url: string; notified?: boolean; actionLabel?: string; actionMessage?: string; diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index b54da66c8..2aff5624b 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -55,7 +55,6 @@ export function SessionDetail({ const sidebarCtx = useSidebarContext(); const startFullscreen = searchParams.get("fullscreen") === "true"; const [showTerminal, setShowTerminal] = useState(false); - const [relaunchError, setRelaunchError] = useState(null); const pr = session.pr; const terminalEnded = isDashboardSessionTerminal(session); const isRestorable = isDashboardSessionRestorable(session); @@ -109,47 +108,6 @@ export function SessionDetail({ } }, [session.id]); - const handleRelaunchClean = useCallback(async () => { - const confirmed = window.confirm( - "This will discard the current orchestrator's conversation and state. Continue?", - ); - if (!confirmed) return; - setRelaunchError(null); - try { - const res = await fetch("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId: session.projectId, clean: true }), - }); - if (!res.ok) { - // Surface server-side errors. Note: a failure here may indicate the - // existing orchestrator was killed but respawn failed — the banner - // tells the user the previous session was terminated so they don't - // assume the page they're looking at is still live. - let message = ""; - try { - const data = (await res.json()) as { error?: string }; - message = data.error ?? ""; - } catch { - message = await res.text().catch(() => ""); - } - throw new Error(message || `HTTP ${res.status}`); - } - // Hard-navigate to the freshly spawned orchestrator's session page. - // Orchestrator session IDs are fixed per project, so this is the same - // path in practice — but reading from the response keeps us correct if - // the contract ever changes (and a hard nav forces the terminal - // WebSocket to reconnect against the new tmux session). - const data = (await res.json()) as { orchestrator?: { id: string } }; - const newId = data.orchestrator?.id ?? session.id; - window.location.href = projectSessionPath(session.projectId, newId); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator"; - console.error("Failed to relaunch orchestrator:", err); - setRelaunchError(message); - } - }, [session.id, session.projectId]); - const orchestratorHref = useMemo(() => { if (isOrchestrator) return null; if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId); @@ -180,35 +138,9 @@ export function SessionDetail({ onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})} onRestore={handleRestore} onKill={handleKill} - onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} />
- {relaunchError ? ( -
-
-
- Relaunch failed: {relaunchError} -
- The previous orchestrator may already be terminated. Try again from the - project dashboard. -
-
- -
-
- ) : null}
{!showTerminal ? (
@@ -218,6 +150,8 @@ export function SessionDetail({ headline={headline} pr={pr} dashboardHref={dashboardHref} + isRestorable={isRestorable} + onRestore={handleRestore} /> ) : ( void; onRestore: () => void; onKill: () => void; - onRelaunchClean?: () => void; } function normalizeActivityLabelForClass(activityLabel: string): string { @@ -56,14 +53,15 @@ function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) { if (stats.length === 0) return null; return ( - <> + + Fleet {stats.map((s) => ( {s.value} {s.label} ))} - + ); } @@ -81,7 +79,6 @@ export function SessionDetailHeader({ onToggleSidebar, onRestore, onKill, - onRelaunchClean, }: SessionDetailHeaderProps) { const pr = session.pr; const allGreen = pr ? isPRMergeReady(pr) : false; @@ -108,8 +105,11 @@ export function SessionDetailHeader({ const headerProjectLabel = projects.find((project) => project.id === session.projectId)?.name ?? session.projectId; - const showHeaderProjectLabel = - headerProjectLabel.trim().toLowerCase() !== "agent orchestrator"; + const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator"; + const showProductBrand = !isOrchestrator; + const showProjectLabel = isOrchestrator || showHeaderProjectLabel; + const showDesktopTitle = !isOrchestrator; + const showDesktopHeaderSep = showProductBrand && showProjectLabel; return (
@@ -148,23 +148,49 @@ export function SessionDetailHeader({ )} ) : null} -
- Agent Orchestrator -
- {showHeaderProjectLabel && ( + {showProductBrand ? ( +
+ Agent Orchestrator +
+ ) : null} + {showDesktopHeaderSep && (
); diff --git a/packages/web/src/components/SessionEndedSummary.tsx b/packages/web/src/components/SessionEndedSummary.tsx index 4ce5cba49..bb44d889c 100644 --- a/packages/web/src/components/SessionEndedSummary.tsx +++ b/packages/web/src/components/SessionEndedSummary.tsx @@ -8,6 +8,8 @@ interface SessionEndedSummaryProps { headline: string; pr: DashboardPR | null; dashboardHref: string; + isRestorable: boolean; + onRestore: () => void; } function formatEndedTime(isoDate: string | null | undefined): string { @@ -42,6 +44,8 @@ export function SessionEndedSummary({ headline, pr, dashboardHref, + isRestorable, + onRestore, }: SessionEndedSummaryProps) { const reason = getEndedSessionReason(session); const summary = getEndedSessionSummary(session, headline); @@ -107,12 +111,25 @@ export function SessionEndedSummary({
+ {isRestorable ? ( + + ) : null} {pr ? ( Open PR #{pr.number} diff --git a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx index 24cfe6a05..6d838dd70 100644 --- a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx @@ -60,10 +60,10 @@ describe("Dashboard done bar", () => { expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument(); }); - it("does not render a restore action for merged sessions", () => { + it("renders a restore action for merged sessions", () => { render(); const toggle = screen.getByText("Done / Terminated").closest("button")!; fireEvent.click(toggle); - expect(screen.queryByRole("button", { name: /restore/i })).toBeNull(); + expect(screen.getByRole("button", { name: /restore/i })).toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx index 731b9487e..27f8cbb48 100644 --- a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx @@ -100,6 +100,30 @@ describe("Dashboard project overview cards", () => { ); }); + it("renders the same Coding/Reviews switch in project-scoped dashboard headers", () => { + render( + , + ); + + expect(screen.getByRole("link", { name: "Coding" })).toHaveAttribute( + "href", + "/projects/my-app", + ); + expect(screen.getByRole("link", { name: "Coding" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Reviews" })).toHaveAttribute( + "href", + "/review?project=my-app", + ); + }); + it("renders a header spawn action when the project has no orchestrator yet", () => { render( ({ + useMuxOptional: () => muxValue, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +import { DashboardNotificationButton } from "../DashboardNotificationButton"; + +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { + session: { id: "worker-1", projectId: "demo" }, + pr: { number: 1, url: "https://github.com/acme/app/pull/1" }, + }, + ...overrides, + }; +} + +function makeNotification(id: string, message: string): DashboardNotificationRecord { + return { + id: `${id}:2026-05-13T12:00:00.000Z`, + receivedAt: `2026-05-13T12:00:0${id}.000Z`, + event: { + id, + type: "session.needs_input", + priority: "action", + sessionId: "worker-1", + projectId: "demo", + timestamp: "2026-05-13T12:00:00.000Z", + message, + data: makeV3Data(), + }, + }; +} + +function makePriorityNotification( + id: string, + priority: string, + message: string, +): DashboardNotificationRecord { + return { + ...makeNotification(id, message), + event: { + ...makeNotification(id, message).event, + priority, + message, + }, + }; +} + +function makeSuccessNotification( + id: string, + type: string, + message: string, +): DashboardNotificationRecord { + return { + ...makeNotification(id, message), + event: { + ...makeNotification(id, message).event, + type, + priority: type === "summary.all_complete" ? "info" : "action", + message, + data: + type === "summary.all_complete" + ? makeV3Data({ semanticType: "summary.all_complete" }) + : makeV3Data({ semanticType: "merge.ready", merge: { ready: true } }), + }, + }; +} + +beforeEach(() => { + window.localStorage.clear(); + muxValue = { + notifications: [ + makeNotification("1", "First notification"), + makeNotification("2", "Second notification"), + ], + notificationLimit: 50, + notificationError: null, + }; +}); + +describe("DashboardNotificationButton", () => { + it("only toggles the panel from an explicit trigger click", () => { + render(); + const trigger = screen.getByRole("button", { name: "Notifications" }); + + fireEvent.mouseEnter(trigger); + expect(screen.queryByRole("dialog", { name: "Notifications" })).not.toBeInTheDocument(); + + fireEvent.focus(trigger); + expect(screen.queryByRole("dialog", { name: "Notifications" })).not.toBeInTheDocument(); + + fireEvent.click(trigger); + expect(screen.getByRole("dialog", { name: "Notifications" })).toBeInTheDocument(); + + fireEvent.click(trigger); + expect(screen.queryByRole("dialog", { name: "Notifications" })).not.toBeInTheDocument(); + }); + + it("toggles one notification and all notifications between read and unread", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Unread 2" })).toBeInTheDocument(); + expect(screen.queryByText("2/50 retained")).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("button", { name: "Mark read" })[0]); + expect(screen.getByRole("tab", { name: "Unread 1" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mark unread" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Mark all read" })); + expect(screen.getByRole("tab", { name: "Unread 0" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mark all unread" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Mark unread" })).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: "Mark all unread" })); + expect(screen.getByRole("tab", { name: "Unread 2" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Mark all read" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Mark read" })).toHaveLength(2); + }); + + it("filters the list to unread notifications", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + fireEvent.click(screen.getAllByRole("button", { name: "Mark read" })[0]); + fireEvent.click(screen.getByRole("tab", { name: "Unread 1" })); + + expect(screen.getByRole("tab", { name: "Unread 1" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect(screen.queryByText("Second notification")).not.toBeInTheDocument(); + expect(screen.getByText("First notification")).toBeInTheDocument(); + }); + + it("uses distinct classes for urgent and action notification colors", () => { + muxValue.notifications = [ + makePriorityNotification("1", "urgent", "Urgent notification"), + makePriorityNotification("2", "action", "Action notification"), + ]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + + expect(screen.getByText("urgent")).toBeInTheDocument(); + expect(screen.getByText("action")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")[0]).toHaveClass("dashboard-notification-item--action"); + expect(screen.getAllByRole("listitem")[1]).toHaveClass("dashboard-notification-item--urgent"); + }); + + it("uses green success labels for approved and all-complete notifications", () => { + muxValue.notifications = [ + makeSuccessNotification("1", "merge.ready", "PR is ready to merge"), + makeSuccessNotification("2", "summary.all_complete", "All sessions complete"), + ]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + + expect(screen.getByText("approved")).toBeInTheDocument(); + expect(screen.getByText("all complete")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")[0]).toHaveClass("dashboard-notification-item--success"); + expect(screen.getAllByRole("listitem")[1]).toHaveClass("dashboard-notification-item--success"); + }); + + it("hides redundant dashboard and PR actions from notification cards", () => { + muxValue.notifications = [ + { + ...makeNotification("1", "CI failed"), + actions: [ + { label: "Open dashboard", url: "http://localhost:3000" }, + { label: "View PR", url: "https://github.com/acme/app/pull/1" }, + { label: "CI run", url: "https://github.com/acme/app/actions/runs/1" }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + + expect(screen.queryByRole("link", { name: "Open dashboard" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "View PR" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "PR" })).toHaveAttribute( + "href", + "https://github.com/acme/app/pull/1", + ); + expect(screen.getByRole("link", { name: "CI run" })).toHaveAttribute( + "href", + "https://github.com/acme/app/actions/runs/1", + ); + }); + + it("does not render unsafe notification URLs", () => { + muxValue.notifications = [ + { + ...makeNotification("1", "Suspicious notification"), + event: { + ...makeNotification("1", "Suspicious notification").event, + data: makeV3Data({ + subject: { + session: { id: "worker-1", projectId: "demo" }, + pr: { number: 1, url: "javascript:alert(1)" }, + }, + review: { url: "data:text/html," }, + }), + }, + actions: [ + { label: "Unsafe action", url: "javascript:alert(1)" }, + { label: "Unsafe external action", url: "https://evil.example/phish" }, + { label: "Safe action", url: "https://github.com/acme/app/actions/runs/1" }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + + expect(screen.queryByRole("link", { name: "PR" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Review" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Unsafe action" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Unsafe external action" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Safe action" })).toHaveAttribute( + "href", + "https://github.com/acme/app/actions/runs/1", + ); + }); +}); diff --git a/packages/web/src/components/__tests__/DegradedProjectState.test.tsx b/packages/web/src/components/__tests__/DegradedProjectState.test.tsx new file mode 100644 index 000000000..561af112f --- /dev/null +++ b/packages/web/src/components/__tests__/DegradedProjectState.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DegradedProjectState } from "@/components/DegradedProjectState"; + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +vi.mock("@/components/RepairDegradedProjectButton", () => ({ + RepairDegradedProjectButton: ({ projectId }: { projectId: string }) => ( + + ), +})); + +const baseProps = { + projectId: "my-project", + resolveError: "Local config at /tmp/my-project/agent-orchestrator.yaml failed validation: bad field", + projectPath: "/tmp/my-project", +}; + +describe("DegradedProjectState", () => { + it("renders the default heading", () => { + render(); + expect(screen.getByText("This project's config failed to load")).toBeInTheDocument(); + }); + + it("renders a custom heading when provided", () => { + render(); + expect(screen.getByText("Custom heading")).toBeInTheDocument(); + }); + + it("displays the resolve error", () => { + render(); + expect(screen.getByText(baseProps.resolveError)).toBeInTheDocument(); + }); + + it("extracts and displays the config path from the resolve error", () => { + render(); + expect( + screen.getByText("/tmp/my-project/agent-orchestrator.yaml"), + ).toBeInTheDocument(); + }); + + it("falls back to the project path when the config path cannot be parsed from the error", () => { + render( + , + ); + expect( + screen.getByText("/tmp/my-project/agent-orchestrator.yaml or .yml"), + ).toBeInTheDocument(); + }); + + it("renders 'Back to dashboard' link pointing to /", () => { + render(); + const link = screen.getByRole("link", { name: "Back to dashboard" }); + expect(link).toHaveAttribute("href", "/"); + }); + + it("does not render an 'Edit settings' link", () => { + render(); + expect(screen.queryByRole("link", { name: "Edit settings" })).not.toBeInTheDocument(); + }); + + it("does not show the repair button for a generic validation error", () => { + render(); + expect(screen.queryByRole("button", { name: /repair/i })).not.toBeInTheDocument(); + }); + + it("shows the repair button when the error indicates a wrapped projects format", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /repair/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx b/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx index d234bba79..cc7e43c46 100644 --- a/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx +++ b/packages/web/src/components/__tests__/DirectTerminal.render.test.tsx @@ -154,7 +154,7 @@ describe("DirectTerminal render", () => { await waitFor(() => expect(screen.getByText("Connected")).toBeInTheDocument()); expect(screen.getByText("ao-orchestrator")).toHaveStyle({ color: "var(--color-accent)" }); - expect(screen.getByText("XDA")).toHaveStyle({ color: "var(--color-accent)" }); + expect(screen.queryByText("XDA")).toBeNull(); }); it("keeps restart and fullscreen actions available in chromeless mode", async () => { diff --git a/packages/web/src/components/__tests__/ReviewDashboard.test.tsx b/packages/web/src/components/__tests__/ReviewDashboard.test.tsx new file mode 100644 index 000000000..68fedc202 --- /dev/null +++ b/packages/web/src/components/__tests__/ReviewDashboard.test.tsx @@ -0,0 +1,188 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ReviewDashboard } from "../ReviewDashboard"; +import type { DashboardReviewRun } from "@/lib/review-types"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/review", +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ + resolvedTheme: "light", + setTheme: vi.fn(), + }), +})); + +function makeRun(overrides: Partial): DashboardReviewRun { + return { + id: "review-run-1", + projectId: "my-app", + projectName: "My App", + linkedSessionId: "app-1", + reviewerSessionId: "app-rev-1", + status: "needs_triage", + createdAt: "2026-05-10T10:00:00.000Z", + updatedAt: "2026-05-10T10:01:00.000Z", + findingCount: 2, + openFindingCount: 1, + dismissedFindingCount: 1, + sentFindingCount: 0, + resolvedFindingCount: 0, + workerTitle: "Add todo filters", + workerBranch: "feat/todo-filters", + workerPrUrl: "https://github.com/acme/todo/pull/7", + workerStatus: "review_pending", + workerActivity: "idle", + workerRuntimeState: "alive", + workerHasRuntime: true, + ...overrides, + }; +} + +describe("ReviewDashboard", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders review runs in review-specific columns", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "My App Reviews" })).toBeInTheDocument(); + expect(screen.getByText("Triage")).toBeInTheDocument(); + expect(screen.getByText("Reviewing")).toBeInTheDocument(); + expect(screen.getByText("Add todo filters")).toBeInTheDocument(); + expect(screen.getByText("Persist completed todos")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /1 open finding/i })).toBeInTheDocument(); + const workerLinks = screen.getAllByRole("link", { name: "Worker" }); + expect( + workerLinks.some((link) => link.getAttribute("href") === "/projects/my-app?session=app-1"), + ).toBe(true); + }); + + it("renders an empty state when there are no review runs", () => { + render( + , + ); + + expect(screen.getByText("No review runs yet")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to coding dashboard" })).toHaveAttribute( + "href", + "/projects/my-app", + ); + }); + + it("can execute multiple queued review runs without a board-wide lock", async () => { + const fetchMock = vi.fn( + () => + new Promise(() => { + // Keep requests pending so the test can assert simultaneous in-flight runs. + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render( + , + ); + + const runButtons = screen.getAllByRole("button", { name: "Run" }); + fireEvent.click(runButtons[0]); + await screen.findByRole("button", { name: "Running" }); + + fireEvent.click(runButtons[1]); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + }); + + it("surfaces a completed failed review run as a failure", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + run: makeRun({ + id: "review-run-1", + reviewerSessionId: "app-rev-1", + status: "failed", + findingCount: 0, + openFindingCount: 0, + dismissedFindingCount: 0, + terminationReason: "Codex review failed: invalid arguments", + }), + }), + ); + vi.stubGlobal("fetch", fetchMock); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Run" })); + + expect( + await screen.findByText(/Review failed: Codex review failed: invalid arguments/i), + ).toBeInTheDocument(); + expect(screen.queryByText("Review completed clean")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx index 5ff28639a..680106c70 100644 --- a/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx +++ b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionCard } from "../SessionCard"; import { makePR, makeSession } from "../../__tests__/helpers"; @@ -135,4 +135,15 @@ describe("SessionCard diff coverage", () => { "kanban-card-enter", ); }); + + it("requests a review from active worker cards", async () => { + const onReview = vi.fn(async () => {}); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Request review" })); + + await waitFor(() => { + expect(onReview).toHaveBeenCalledWith("reviewable-1"); + }); + }); }); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index 07a77e48f..bffbbc54a 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -223,6 +223,32 @@ describe("SessionDetail desktop layout", () => { "/projects/my-app", ); expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument(); + // The ended-session body also exposes a prominent "Restore session" button + // so users don't have to find the small icon in the header. + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); + }); + + it("shows the Restore button in the ended-summary for pr_merged sessions (status=cleanup)", () => { + render( + , + ); + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); }); it("keeps restored working sessions live when terminatedAt is stale", () => { @@ -271,7 +297,61 @@ describe("SessionDetail desktop layout", () => { ); }); - it("shows restore for restorable orchestrator sessions", () => { + it("does not open a blank terminal when activity exited but lifecycle still reports alive", () => { + render( + , + ); + + expect(screen.getByRole("region", { name: "Session ended summary" })).toBeInTheDocument(); + expect(screen.getByText("Terminal ended")).toBeInTheDocument(); + expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument(); + expect( + within(screen.getByRole("banner")).getByRole("button", { name: "Restore" }), + ).toBeInTheDocument(); + }); + + it("keeps restore in the ended summary but not the top bar for restorable orchestrators", () => { render( { />, ); - expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toHaveClass( - "dashboard-app-btn--restore", - ); + expect( + within(screen.getByRole("banner")).queryByRole("button", { name: "Restore" }), + ).not.toBeInTheDocument(); + expect( + within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", { + name: "Restore session", + }), + ).toBeInTheDocument(); + expect( + within(screen.getByRole("banner")).getByRole("link", { name: "Open Kanban" }), + ).toHaveAttribute("href", "/projects/my-app"); + expect( + within(screen.getByRole("banner")).queryByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ).not.toBeInTheDocument(); expect( within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" }), ).not.toBeInTheDocument(); }); - it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); - const hrefSetter = vi.fn(); - Object.defineProperty(window, "location", { - value: { - ...window.location, - set href(value: string) { - hrefSetter(value); - }, - }, - writable: true, - }); - vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url === "/api/orchestrators") { - return { - ok: true, - json: async () => ({ - orchestrator: { id: "my-app-orchestrator", projectId: "my-app" }, - }), - } as Response; - } - return { ok: true, json: async () => ({}), text: async () => "" } as Response; - }); - + it("renders a scoped, non-repetitive orchestrator top bar", () => { render( { projectId: "my-app", status: "working", activity: "active", + branch: "orchestrator/my-app-orchestrator", summary: "Project orchestrator", + displayName: "# My App Orchestrator", + pr: makePR({ number: 777 }), })} isOrchestrator orchestratorZones={{ merge: 0, respond: 0, - review: 0, - pending: 0, - working: 0, - done: 0, + review: 1, + pending: 2, + working: 1, + done: 4, }} projectOrchestratorId="my-app-orchestrator" projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]} />, ); - const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", { - name: /launch orchestrator \(clean context\)/i, - }); - fireEvent.click(relaunchBtn); + const banner = within(screen.getByRole("banner")); - expect(confirmSpy).toHaveBeenCalled(); - await act(async () => {}); - - expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId: "my-app", clean: true }), - }); - expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator"); - - confirmSpy.mockRestore(); - }); - - it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => { - render( - , + expect(banner.getByText("My App")).toBeInTheDocument(); + expect(banner.getByText("Orchestrator")).toBeInTheDocument(); + expect(banner.getByText("Active")).toBeInTheDocument(); + expect(banner.getByText("Fleet")).toBeInTheDocument(); + expect(banner.getByText("review")).toBeInTheDocument(); + expect(banner.getByText("working")).toBeInTheDocument(); + expect(banner.getByText("pending")).toBeInTheDocument(); + expect(banner.getByText("done")).toBeInTheDocument(); + expect(banner.queryByText("my-app-orchestrator")).not.toBeInTheDocument(); + expect(banner.getByRole("link", { name: "Open Kanban" })).toHaveAttribute( + "href", + "/projects/my-app", ); - - expect( - within(screen.getByRole("banner")).getByRole("button", { - name: /launch orchestrator \(clean context\)/i, - }), - ).toBeInTheDocument(); + expect(banner.queryByText("Agent Orchestrator")).not.toBeInTheDocument(); + expect(banner.queryByText("# My App Orchestrator")).not.toBeInTheDocument(); + expect(banner.queryByText("orchestrator/my-app-orchestrator")).not.toBeInTheDocument(); + expect(banner.queryByRole("link", { name: "PR #777" })).not.toBeInTheDocument(); }); - it("surfaces a relaunch error banner when POST fails after confirm", async () => { - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); - vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url === "/api/orchestrators") { - return { - ok: false, - status: 500, - json: async () => ({ error: "kill+respawn failed" }), - text: async () => "kill+respawn failed", - } as Response; - } - return { ok: true, json: async () => ({}), text: async () => "" } as Response; - }); - + it("shows the project name for the Agent Orchestrator project header", () => { render( , ); - fireEvent.click( - within(screen.getByRole("banner")).getByRole("button", { - name: /launch orchestrator \(clean context\)/i, - }), + const banner = within(screen.getByRole("banner")); + + expect(banner.getByText("Agent Orchestrator")).toBeInTheDocument(); + expect(banner.getByText("Orchestrator")).toHaveClass("session-detail-mode-badge--neutral"); + expect(banner.getByRole("link", { name: "Open Kanban" })).toHaveAttribute( + "href", + "/projects/agent-orchestrator", ); - - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent(/kill\+respawn failed/i); - expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i); - - fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" })); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - - confirmSpy.mockRestore(); }); it("does not render Relaunch (clean) on worker sessions", () => { @@ -507,7 +539,7 @@ describe("SessionDetail desktop layout", () => { expect( within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }), ).not.toBeInTheDocument(); - expect(screen.getByText("orchestrator")).toBeInTheDocument(); + expect(within(screen.getByRole("banner")).getByText("Orchestrator")).toBeInTheDocument(); }); it("shows the main orchestrator button when an orchestrator target exists", () => { diff --git a/packages/web/src/components/terminal/TerminalControls.tsx b/packages/web/src/components/terminal/TerminalControls.tsx index 84fb7a1b0..e09ea2961 100644 --- a/packages/web/src/components/terminal/TerminalControls.tsx +++ b/packages/web/src/components/terminal/TerminalControls.tsx @@ -217,7 +217,7 @@ export function TerminalControls({
{/* Pane label — matches the workspace pane-header style used elsewhere */} TERMINAL - {/* Identity group: session name on top, status+XDA below on mobile */} + {/* Identity group: session name on top, status below on mobile */}
{sessionId} @@ -229,15 +229,6 @@ export function TerminalControls({ > {statusText} - - XDA -
diff --git a/packages/web/src/lib/__tests__/review-types.test.ts b/packages/web/src/lib/__tests__/review-types.test.ts new file mode 100644 index 000000000..c1b85bf53 --- /dev/null +++ b/packages/web/src/lib/__tests__/review-types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getReviewBoardColumn, type DashboardReviewRun } from "../review-types"; + +function makeRun(status: DashboardReviewRun["status"]): Pick { + return { status }; +} + +describe("getReviewBoardColumn", () => { + it("maps reviewer run statuses into review board columns", () => { + expect(getReviewBoardColumn(makeRun("queued"))).toBe("queued"); + expect(getReviewBoardColumn(makeRun("preparing"))).toBe("queued"); + expect(getReviewBoardColumn(makeRun("running"))).toBe("reviewing"); + expect(getReviewBoardColumn(makeRun("needs_triage"))).toBe("triage"); + expect(getReviewBoardColumn(makeRun("sent_to_agent"))).toBe("waiting"); + expect(getReviewBoardColumn(makeRun("waiting_update"))).toBe("waiting"); + expect(getReviewBoardColumn(makeRun("clean"))).toBe("clean"); + expect(getReviewBoardColumn(makeRun("failed"))).toBe("failed"); + expect(getReviewBoardColumn(makeRun("cancelled"))).toBe("failed"); + expect(getReviewBoardColumn(makeRun("outdated"))).toBe("outdated"); + }); +}); diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 7b496f49b..580519b3e 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -31,6 +31,7 @@ import type { DashboardSession } from "../types"; // Helper to create a minimal Session for testing function createCoreSession(overrides?: Partial): Session { + const { metadata: overrideMetadata, ...sessionOverrides } = overrides ?? {}; const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z")); lifecycle.session.state = "working"; lifecycle.session.reason = "task_in_progress"; @@ -57,8 +58,8 @@ function createCoreSession(overrides?: Partial): Session { agentInfo: null, createdAt: new Date("2025-01-01T00:00:00Z"), lastActivityAt: new Date("2025-01-01T01:00:00Z"), - metadata: {}, - ...overrides, + metadata: { agent: "mock-agent", ...(overrideMetadata ?? {}) }, + ...sessionOverrides, }; } @@ -1196,7 +1197,79 @@ describe("enrichSessionsMetadata", () => { expect(agent.getSessionInfo).toHaveBeenCalledTimes(2); // sessions 1 and 2 only }); - it("should use default agent when project has no agent override", async () => { + it("uses persisted session agent instead of project default for summary enrichment", async () => { + const codexAgent = mockAgent("Wrong Codex summary"); + codexAgent.name = "codex"; + const gooseAgent = mockAgent("Goose summary"); + gooseAgent.name = "goose"; + const registry = { + get: vi.fn((slot: string, name: string) => { + if (slot !== "agent") return null; + if (name === "codex") return codexAgent; + if (name === "goose") return gooseAgent; + return null; + }), + register: vi.fn(), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), + } as unknown as PluginRegistry; + const mixedAgentConfig = { + ...testConfig, + defaults: { ...testConfig.defaults, agent: "codex" }, + projects: { + test: { ...testProject, agent: "codex" } as ProjectConfig, + }, + } as OrchestratorConfig; + + const core = createCoreSession({ metadata: { agent: "goose" } }); + const dashboard = sessionToDashboard(core); + + await enrichSessionsMetadata([core], [dashboard], mixedAgentConfig, registry); + + expect(registry.get).toHaveBeenCalledWith("agent", "goose"); + expect(registry.get).not.toHaveBeenCalledWith("agent", "codex"); + expect(codexAgent.getSessionInfo).not.toHaveBeenCalled(); + expect(gooseAgent.getSessionInfo).toHaveBeenCalledWith(core); + expect(dashboard.summary).toBe("Goose summary"); + }); + + it("uses persisted session agent when no project can be resolved", async () => { + const codexAgent = mockAgent("Wrong Codex summary"); + codexAgent.name = "codex"; + const gooseAgent = mockAgent("Orphan Goose summary"); + gooseAgent.name = "goose"; + const registry = { + get: vi.fn((slot: string, name: string) => { + if (slot !== "agent") return null; + if (name === "codex") return codexAgent; + if (name === "goose") return gooseAgent; + return null; + }), + register: vi.fn(), + list: vi.fn().mockReturnValue([]), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), + } as unknown as PluginRegistry; + const noProjectsConfig = { + ...testConfig, + defaults: { ...testConfig.defaults, agent: "codex" }, + projects: {}, + } as OrchestratorConfig; + + const core = createCoreSession({ projectId: "deleted", metadata: { agent: "goose" } }); + const dashboard = sessionToDashboard(core); + + await enrichSessionsMetadata([core], [dashboard], noProjectsConfig, registry); + + expect(registry.get).toHaveBeenCalledWith("agent", "goose"); + expect(registry.get).not.toHaveBeenCalledWith("agent", "codex"); + expect(codexAgent.getSessionInfo).not.toHaveBeenCalled(); + expect(gooseAgent.getSessionInfo).toHaveBeenCalledWith(core); + expect(dashboard.summary).toBe("Orphan Goose summary"); + }); + + it("uses normalized session agent metadata when project has no agent override", async () => { const tracker = mockTracker(); const agent = mockAgent("From default agent"); const registry = mockRegistry(tracker, agent); @@ -1212,7 +1285,7 @@ describe("enrichSessionsMetadata", () => { await enrichSessionsMetadata([core], [dashboard], configNoProjectAgent, registry); - // Falls back to config.defaults.agent + // Uses the normalized Session metadata, not project/default inference. expect(registry.get).toHaveBeenCalledWith("agent", "mock-agent"); expect(dashboard.summary).toBe("From default agent"); }); diff --git a/packages/web/src/lib/__tests__/types.test.ts b/packages/web/src/lib/__tests__/types.test.ts index ceee22169..350eee852 100644 --- a/packages/web/src/lib/__tests__/types.test.ts +++ b/packages/web/src/lib/__tests__/types.test.ts @@ -12,7 +12,9 @@ import { TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES, isDashboardSessionDone, + isDashboardRuntimeEnded, isDashboardSessionRestorable, + isDashboardSessionTerminal, type DashboardSession, type DashboardPR, } from "../types"; @@ -264,7 +266,50 @@ describe("getAttentionLevel", () => { }); describe("restore affordances", () => { - it("should not mark merged sessions as restorable", () => { + it("treats exited activity as terminal even when lifecycle runtime is still alive", () => { + const session = createSession({ + status: "review_pending", + activity: "exited", + lifecycle: { + sessionState: "idle", + sessionReason: "awaiting_external_review", + prState: "open", + prReason: "review_pending", + runtimeState: "alive", + runtimeReason: "process_running", + session: { + state: "idle", + reason: "awaiting_external_review", + label: "idle", + reasonLabel: "awaiting external review", + }, + pr: { + state: "open", + reason: "review_pending", + label: "open", + reasonLabel: "review pending", + }, + runtime: { + state: "alive", + reason: "process_running", + label: "alive", + reasonLabel: "process running", + }, + legacyStatus: "review_pending", + evidence: null, + detectingAttempts: 0, + detectingEscalatedAt: null, + summary: "Waiting for review", + guidance: null, + }, + }); + + expect(isDashboardSessionTerminal(session)).toBe(true); + expect(isDashboardRuntimeEnded(session)).toBe(true); + expect(isDashboardSessionRestorable(session)).toBe(true); + }); + + it("should not mark a running merged session as restorable (runtime still alive)", () => { const session = createSession({ status: "merged", lifecycle: { @@ -298,6 +343,41 @@ describe("getAttentionLevel", () => { expect(isDashboardSessionRestorable(session)).toBe(false); }); + + it("should mark a pr_merged-cleanup session as restorable", () => { + const session = createSession({ + status: "cleanup", + lifecycle: { + sessionState: "terminated", + sessionReason: "pr_merged", + prState: "merged", + prReason: "merged", + runtimeState: "missing", + runtimeReason: "pr_merged_cleanup", + session: { + state: "terminated", + reason: "pr_merged", + label: "terminated", + reasonLabel: "pr merged", + }, + pr: { state: "merged", reason: "merged", label: "merged", reasonLabel: "merged" }, + runtime: { + state: "missing", + reason: "pr_merged_cleanup", + label: "missing", + reasonLabel: "pr merged cleanup", + }, + legacyStatus: "cleanup", + evidence: null, + detectingAttempts: 0, + detectingEscalatedAt: null, + summary: "Session cleaned up after PR merge", + guidance: null, + }, + }); + + expect(isDashboardSessionRestorable(session)).toBe(true); + }); }); describe("respond state", () => { diff --git a/packages/web/src/lib/mux-protocol.ts b/packages/web/src/lib/mux-protocol.ts index 64e81b3e2..4777bbe47 100644 --- a/packages/web/src/lib/mux-protocol.ts +++ b/packages/web/src/lib/mux-protocol.ts @@ -1,5 +1,16 @@ +import type { + DashboardNotificationRecord, + SerializedDashboardAction as DashboardNotificationAction, + SerializedDashboardEvent as DashboardNotificationEvent, +} from "@aoagents/ao-core"; import type { AttentionLevel } from "./types"; +export type { + DashboardNotificationAction, + DashboardNotificationEvent, + DashboardNotificationRecord, +}; + // ── Client → Server ── export type ClientMessage = @@ -8,7 +19,7 @@ export 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 ── @@ -19,6 +30,13 @@ export 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 }; diff --git a/packages/web/src/lib/review-page-data.ts b/packages/web/src/lib/review-page-data.ts new file mode 100644 index 000000000..1260d84c2 --- /dev/null +++ b/packages/web/src/lib/review-page-data.ts @@ -0,0 +1,178 @@ +import "server-only"; + +import { + createCodeReviewStore, + isOrchestratorSession, + isRestorable, + isTerminalSession, + markOutdatedCodeReviewRunsForSession, +} from "@aoagents/ao-core"; +import { getServices } from "@/lib/services"; +import { + getAllProjects, + getPrimaryProjectId, + getProjectName, + type ProjectInfo, +} from "@/lib/project-name"; +import type { DashboardReviewRun, ReviewWorkerOption } from "@/lib/review-types"; +import { listDashboardOrchestrators, sessionToDashboard } from "@/lib/serialize"; +import type { DashboardOrchestratorLink, DashboardSession } from "@/lib/types"; + +interface ReviewPageData { + runs: DashboardReviewRun[]; + sidebarSessions: DashboardSession[]; + orchestrators: DashboardOrchestratorLink[]; + workerOptions: ReviewWorkerOption[]; + projectName: string; + projects: ProjectInfo[]; + selectedProjectId?: string; + dashboardLoadError?: string; +} + +function formatReviewLoadError(err: unknown): string { + if (err instanceof Error && err.message.trim()) { + return err.message.split(/\r?\n/)[0]?.trim() || "Failed to load review data."; + } + return "Failed to load review data."; +} + +export function getReviewProjectName(projectFilter: string | undefined): string { + if (projectFilter === "all") return "All Projects"; + const projects = getAllProjects(); + if (projectFilter) { + const selectedProject = projects.find((project) => project.id === projectFilter); + if (selectedProject) return selectedProject.name; + } + return getProjectName(); +} + +export function resolveReviewProjectFilter(project?: string): string { + if (project === "all") return "all"; + const projects = getAllProjects(); + if (project && projects.some((entry) => entry.id === project)) { + return project; + } + return getPrimaryProjectId(); +} + +export async function getReviewPageData(project?: string): Promise { + const projectFilter = resolveReviewProjectFilter(project); + const pageData: ReviewPageData = { + runs: [], + sidebarSessions: [], + orchestrators: [], + workerOptions: [], + projectName: getReviewProjectName(projectFilter), + projects: getAllProjects(), + selectedProjectId: projectFilter === "all" ? undefined : projectFilter, + }; + + try { + const { config, sessionManager } = await getServices(); + const projectIds = + projectFilter === "all" + ? Object.keys(config.projects) + : config.projects[projectFilter] + ? [projectFilter] + : []; + const allSessions = await sessionManager.listCached(); + const visibleSessions = allSessions.filter((session) => projectIds.includes(session.projectId)); + const allSessionPrefixes = Object.entries(config.projects).map( + ([projectId, project]) => project.sessionPrefix ?? projectId, + ); + const workerSessionsById = new Map( + visibleSessions + .filter( + (session) => + !isOrchestratorSession( + session, + config.projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ), + ) + .map((session) => [session.id, session]), + ); + const workerSessions = [...workerSessionsById.values()]; + + pageData.sidebarSessions = visibleSessions.map(sessionToDashboard); + const visibleSessionsById = new Map(visibleSessions.map((session) => [session.id, session])); + pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects).map( + (orchestrator) => { + const session = visibleSessionsById.get(orchestrator.id); + return { + ...orchestrator, + status: session?.status ?? null, + activity: session?.activity ?? null, + runtimeState: session?.lifecycle.runtime.state ?? null, + hasRuntime: session?.runtimeHandle !== null && session?.runtimeHandle !== undefined, + isTerminal: session ? isTerminalSession(session) : false, + isRestorable: session ? isRestorable(session) : false, + }; + }, + ); + pageData.workerOptions = workerSessions.map((session) => { + const project = config.projects[session.projectId]; + const title = + session.metadata["displayName"] ?? + session.metadata["issueTitle"] ?? + session.metadata["pinnedSummary"] ?? + session.agentInfo?.summary ?? + session.branch ?? + session.id; + return { + id: session.id, + projectId: session.projectId, + projectName: project?.name ?? session.projectId, + title, + branch: session.branch ?? null, + status: session.status, + activity: session.activity ?? null, + runtimeState: session.lifecycle.runtime.state, + hasRuntime: session.runtimeHandle !== null && session.runtimeHandle !== undefined, + prNumber: session.pr?.number ?? null, + prUrl: session.pr?.url ?? null, + }; + }); + + const runs: DashboardReviewRun[] = []; + + for (const projectId of projectIds) { + const project = config.projects[projectId]; + if (!project) continue; + const store = createCodeReviewStore(projectId); + const projectWorkers = workerSessions.filter((session) => session.projectId === projectId); + + for (const worker of projectWorkers) { + await markOutdatedCodeReviewRunsForSession({ store, session: worker }); + } + + runs.push( + ...store.listRunSummaries().map((run) => { + const worker = workerSessionsById.get(run.linkedSessionId); + return { + ...run, + projectName: project.name, + workerTitle: + worker?.metadata["displayName"] ?? + worker?.metadata["issueTitle"] ?? + worker?.metadata["pinnedSummary"] ?? + worker?.agentInfo?.summary ?? + null, + workerBranch: worker?.branch ?? null, + workerPrUrl: worker?.pr?.url ?? run.prUrl ?? null, + workerStatus: worker?.status ?? null, + workerActivity: worker?.activity ?? null, + workerRuntimeState: worker?.lifecycle.runtime.state ?? null, + workerHasRuntime: worker?.runtimeHandle !== null && worker?.runtimeHandle !== undefined, + }; + }), + ); + } + + pageData.runs = runs; + } catch (err) { + pageData.dashboardLoadError = formatReviewLoadError(err); + } + + return pageData; +} diff --git a/packages/web/src/lib/review-types.ts b/packages/web/src/lib/review-types.ts new file mode 100644 index 000000000..64af3640e --- /dev/null +++ b/packages/web/src/lib/review-types.ts @@ -0,0 +1,77 @@ +import type { CodeReviewRunSummary } from "@aoagents/ao-core"; + +export type ReviewBoardColumn = + | "queued" + | "reviewing" + | "triage" + | "waiting" + | "clean" + | "failed" + | "outdated"; + +export interface DashboardReviewRun extends CodeReviewRunSummary { + projectName: string; + workerTitle: string | null; + workerBranch: string | null; + workerPrUrl: string | null; + workerStatus: string | null; + workerActivity: string | null; + workerRuntimeState: string | null; + workerHasRuntime: boolean; +} + +export interface ReviewWorkerOption { + id: string; + projectId: string; + projectName: string; + title: string; + branch: string | null; + status: string; + activity: string | null; + runtimeState: string | null; + hasRuntime: boolean; + prNumber: number | null; + prUrl: string | null; +} + +export const REVIEW_BOARD_COLUMNS: ReviewBoardColumn[] = [ + "queued", + "reviewing", + "triage", + "waiting", + "clean", + "failed", + "outdated", +]; + +export const REVIEW_COLUMN_LABELS: Record = { + queued: "Queued", + reviewing: "Reviewing", + triage: "Triage", + waiting: "Waiting", + clean: "Clean", + failed: "Failed", + outdated: "Outdated", +}; + +export function getReviewBoardColumn(run: Pick): ReviewBoardColumn { + switch (run.status) { + case "queued": + case "preparing": + return "queued"; + case "running": + return "reviewing"; + case "needs_triage": + return "triage"; + case "sent_to_agent": + case "waiting_update": + return "waiting"; + case "clean": + return "clean"; + case "failed": + case "cancelled": + return "failed"; + case "outdated": + return "outdated"; + } +} diff --git a/packages/web/src/lib/routes.ts b/packages/web/src/lib/routes.ts index 22862ea99..44bfb8e35 100644 --- a/packages/web/src/lib/routes.ts +++ b/packages/web/src/lib/routes.ts @@ -2,6 +2,14 @@ export function projectDashboardPath(projectId: string): string { return `/projects/${encodeURIComponent(projectId)}`; } +export function projectDashboardSessionPath(projectId: string, sessionId: string): string { + return `${projectDashboardPath(projectId)}?session=${encodeURIComponent(sessionId)}`; +} + +export function projectReviewPath(projectId: string | undefined): string { + return projectId ? `/review?project=${encodeURIComponent(projectId)}` : "/review?project=all"; +} + export function projectSessionPath(projectId: string, sessionId: string): string { return `${projectDashboardPath(projectId)}/sessions/${encodeURIComponent(sessionId)}`; } diff --git a/packages/web/src/lib/scm-webhooks.ts b/packages/web/src/lib/scm-webhooks.ts index 63f8d301a..360a8ff6c 100644 --- a/packages/web/src/lib/scm-webhooks.ts +++ b/packages/web/src/lib/scm-webhooks.ts @@ -40,7 +40,7 @@ export function findWebhookProjects( const webhookPath = getProjectWebhookPath(project); if (!webhookPath || webhookPath !== pathname) return []; const scm = registry.get("scm", project.scm.plugin); - if (!scm?.parseWebhook || !scm.verifyWebhook) return []; + if (!scm?.parseWebhook) return []; return [{ projectId, project, scm }]; }); } diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index 2f8b34b6e..a52ee872e 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -547,7 +547,7 @@ function prepareSessionMetadataEnrichment( // Agent summaries (local disk I/O — reads agent JSONL) const summaryPromises = coreSessions.map((core, i) => { if (dashboardSessions[i].summary) return Promise.resolve(); - const agentName = projects[i]?.agent ?? config.defaults.agent; + const agentName = core.metadata["agent"]; if (!agentName) return Promise.resolve(); const agent = registry.get("agent", agentName); if (!agent) return Promise.resolve(); diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index ef0c5402c..b20b23081 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -39,6 +39,7 @@ import pluginAgentClaudeCode from "@aoagents/ao-plugin-agent-claude-code"; import pluginAgentCodex from "@aoagents/ao-plugin-agent-codex"; import pluginAgentCursor from "@aoagents/ao-plugin-agent-cursor"; import pluginAgentKimicode from "@aoagents/ao-plugin-agent-kimicode"; +import pluginAgentGrok from "@aoagents/ao-plugin-agent-grok"; import pluginAgentOpencode from "@aoagents/ao-plugin-agent-opencode"; import pluginWorkspaceWorktree from "@aoagents/ao-plugin-workspace-worktree"; import pluginScmGithub from "@aoagents/ao-plugin-scm-github"; @@ -111,6 +112,7 @@ async function initServices(): Promise { registry.register(pluginAgentCodex); registry.register(pluginAgentCursor); registry.register(pluginAgentKimicode); + registry.register(pluginAgentGrok); registry.register(pluginAgentOpencode); registry.register(pluginWorkspaceWorktree); registry.register(pluginScmGithub); diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 5174e0782..82f7daad2 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -250,6 +250,12 @@ export interface DashboardOrchestratorLink { id: string; projectId: string; projectName: string; + status?: string | null; + activity?: string | null; + runtimeState?: string | null; + hasRuntime?: boolean; + isTerminal?: boolean; + isRestorable?: boolean; } /** @@ -403,30 +409,31 @@ export function isDashboardSessionDone(session: DashboardSession): boolean { return session.pr?.state === "merged"; } +function hasTerminalActivity(session: DashboardSession): boolean { + return session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity); +} + export function isDashboardSessionTerminal(session: DashboardSession): boolean { if (session.lifecycle) { return ( isDashboardSessionDone(session) || session.lifecycle.runtimeState === "missing" || - session.lifecycle.runtimeState === "exited" + session.lifecycle.runtimeState === "exited" || + hasTerminalActivity(session) ); } - return ( - TERMINAL_STATUSES.has(session.status) || - (session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity)) - ); + return TERMINAL_STATUSES.has(session.status) || hasTerminalActivity(session); } export function isDashboardRuntimeEnded(session: DashboardSession): boolean { if (session.lifecycle) { return ( - session.lifecycle.runtimeState === "missing" || session.lifecycle.runtimeState === "exited" + session.lifecycle.runtimeState === "missing" || + session.lifecycle.runtimeState === "exited" || + hasTerminalActivity(session) ); } - return ( - TERMINAL_STATUSES.has(session.status) || - (session.activity !== null && TERMINAL_ACTIVITIES.has(session.activity)) - ); + return TERMINAL_STATUSES.has(session.status) || hasTerminalActivity(session); } export function isDashboardSessionRestorable(session: DashboardSession): boolean { @@ -435,13 +442,15 @@ export function isDashboardSessionRestorable(session: DashboardSession): boolean session.lifecycle.sessionState === "done" || isDashboardSessionTerminated(session) || session.lifecycle.runtimeState === "missing" || - session.lifecycle.runtimeState === "exited"; + session.lifecycle.runtimeState === "exited" || + hasTerminalActivity(session); return ( - terminalByCoreTruth && session.lifecycle.prState !== "merged" && session.status !== "merged" + terminalByCoreTruth && + !NON_RESTORABLE_STATUSES.has(session.status) && + session.status !== "merged" ); } - if (!isDashboardSessionTerminal(session)) return false; - return session.pr?.state !== "merged" && session.status !== "merged"; + return isDashboardSessionTerminal(session) && !NON_RESTORABLE_STATUSES.has(session.status); } /** diff --git a/packages/web/src/providers/MuxProvider.tsx b/packages/web/src/providers/MuxProvider.tsx index 26e33171e..d3d5e2954 100644 --- a/packages/web/src/providers/MuxProvider.tsx +++ b/packages/web/src/providers/MuxProvider.tsx @@ -1,7 +1,12 @@ "use client"; import React, { useEffect, useRef, useState, useMemo, useCallback, type ReactNode } from "react"; -import type { ClientMessage, ServerMessage, SessionPatch } from "@/lib/mux-protocol"; +import type { + ClientMessage, + DashboardNotificationRecord, + ServerMessage, + SessionPatch, +} from "@/lib/mux-protocol"; interface MuxContextValue { subscribeTerminal: ( @@ -15,12 +20,35 @@ interface MuxContextValue { resizeTerminal: (id: string, cols: number, rows: number, projectId?: string) => void; status: "connecting" | "connected" | "reconnecting" | "disconnected"; sessions: SessionPatch[]; + notifications: DashboardNotificationRecord[]; + notificationLimit: number; /** Last session-fetch error from the server, null when healthy. */ lastError: string | null; + /** Last notification-store error from the server, null when healthy. */ + notificationError: string | null; } const MuxContext = React.createContext(undefined); +function notificationKey(record: DashboardNotificationRecord): string { + return `${record.id}:${record.receivedAt}`; +} + +function mergeNotifications( + current: DashboardNotificationRecord[], + appended: DashboardNotificationRecord[], + limit: number, +): DashboardNotificationRecord[] { + const byKey = new Map(); + for (const record of current) { + byKey.set(notificationKey(record), record); + } + for (const record of appended) { + byKey.set(notificationKey(record), record); + } + return [...byKey.values()].slice(-limit); +} + export function useMux(): MuxContextValue { const context = React.useContext(MuxContext); if (!context) { @@ -92,7 +120,10 @@ export function MuxProvider({ children }: { children: ReactNode }) { "connecting" | "connected" | "reconnecting" | "disconnected" >("connecting"); const [sessions, setSessions] = useState([]); + const [notifications, setNotifications] = useState([]); + const [notificationLimit, setNotificationLimit] = useState(50); const [lastError, setLastError] = useState(null); + const [notificationError, setNotificationError] = useState(null); const reconnectAttempt = useRef(0); const reconnectTimer = useRef | null>(null); const runtimeConfigRef = useRef<{ directTerminalPort?: string; proxyWsPath?: string }>({}); @@ -136,7 +167,7 @@ export function MuxProvider({ children }: { children: ReactNode }) { // Always subscribe to sessions const subMsg: ClientMessage = { ch: "subscribe", - topics: ["sessions"], + topics: ["sessions", "notifications"], }; ws.send(JSON.stringify(subMsg)); }); @@ -185,6 +216,20 @@ export function MuxProvider({ children }: { children: ReactNode }) { } else if (msg.type === "error") { setLastError(msg.error); } + } else if (msg.ch === "notifications") { + if (msg.type === "snapshot") { + setNotificationLimit(msg.limit); + setNotifications(msg.notifications.slice(-msg.limit)); + setNotificationError(null); + } else if (msg.type === "append") { + setNotificationLimit(msg.limit); + setNotifications((current) => + mergeNotifications(current, msg.notifications, msg.limit), + ); + setNotificationError(null); + } else if (msg.type === "error") { + setNotificationError(msg.error); + } } } catch (err) { console.error("[MuxProvider] Error processing message:", err); @@ -356,7 +401,10 @@ export function MuxProvider({ children }: { children: ReactNode }) { resizeTerminal, status, sessions, + notifications, + notificationLimit, lastError, + notificationError, }), [ subscribeTerminal, @@ -366,7 +414,10 @@ export function MuxProvider({ children }: { children: ReactNode }) { resizeTerminal, status, sessions, + notifications, + notificationLimit, lastError, + notificationError, ], ); diff --git a/packages/web/src/providers/__tests__/MuxProvider.test.tsx b/packages/web/src/providers/__tests__/MuxProvider.test.tsx index 878c2aed5..e2af691fe 100644 --- a/packages/web/src/providers/__tests__/MuxProvider.test.tsx +++ b/packages/web/src/providers/__tests__/MuxProvider.test.tsx @@ -168,6 +168,7 @@ describe("MuxProvider connection lifecycle", () => { }); expect(subMsg).toBeDefined(); expect((JSON.parse(subMsg!) as Record).topics).toContain("sessions"); + expect((JSON.parse(subMsg!) as Record).topics).toContain("notifications"); }); it("transitions to reconnecting on close", async () => { @@ -509,6 +510,80 @@ describe("MuxProvider message handling", () => { expect(result.current.sessions[0].id).toBe("s1"); }); + it("updates notifications on snapshot and append messages", async () => { + const { result, ws } = await setupConnected(); + + act(() => + ws.simulateMessage({ + ch: "notifications", + type: "snapshot", + limit: 2, + notifications: [ + { + id: "n1", + receivedAt: "2026-05-13T10:00:00.000Z", + event: { + id: "evt-1", + type: "summary.all_complete", + priority: "info", + sessionId: "s1", + projectId: "demo", + timestamp: "2026-05-13T10:00:00.000Z", + message: "Done", + data: {}, + }, + }, + ], + }), + ); + + expect(result.current.notifications.map((notification) => notification.id)).toEqual(["n1"]); + expect(result.current.notificationLimit).toBe(2); + + act(() => + ws.simulateMessage({ + ch: "notifications", + type: "append", + limit: 2, + notifications: [ + { + id: "n2", + receivedAt: "2026-05-13T10:01:00.000Z", + event: { + id: "evt-2", + type: "session.needs_input", + priority: "action", + sessionId: "s2", + projectId: "demo", + timestamp: "2026-05-13T10:01:00.000Z", + message: "Needs input", + data: {}, + }, + }, + { + id: "n3", + receivedAt: "2026-05-13T10:02:00.000Z", + event: { + id: "evt-3", + type: "ci.failing", + priority: "action", + sessionId: "s3", + projectId: "demo", + timestamp: "2026-05-13T10:02:00.000Z", + message: "CI failed", + data: {}, + }, + }, + ], + }), + ); + + expect(result.current.notifications.map((notification) => notification.id)).toEqual([ + "n2", + "n3", + ]); + }); + it("handles malformed JSON message without crashing", async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const { result } = await setupConnected(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b7d3e9a0..2a5873749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@aoagents/ao-core': specifier: workspace:* version: link:../core + '@aoagents/ao-notifier-macos': + specifier: workspace:* + version: link:../notifier-macos '@aoagents/ao-plugin-agent-aider': specifier: workspace:* version: link:../plugins/agent-aider @@ -67,6 +70,9 @@ importers: '@aoagents/ao-plugin-agent-cursor': specifier: workspace:* version: link:../plugins/agent-cursor + '@aoagents/ao-plugin-agent-grok': + specifier: workspace:* + version: link:../plugins/agent-grok '@aoagents/ao-plugin-agent-kimicode': specifier: workspace:* version: link:../plugins/agent-kimicode @@ -76,6 +82,9 @@ importers: '@aoagents/ao-plugin-notifier-composio': specifier: workspace:* version: link:../plugins/notifier-composio + '@aoagents/ao-plugin-notifier-dashboard': + specifier: workspace:* + version: link:../plugins/notifier-dashboard '@aoagents/ao-plugin-notifier-desktop': specifier: workspace:* version: link:../plugins/notifier-desktop @@ -124,6 +133,9 @@ importers: '@clack/prompts': specifier: ^0.9.1 version: 0.9.1 + '@composio/core': + specifier: ^0.9.0 + version: 0.9.0(ws@8.20.0)(zod@3.25.76) chalk: specifier: ^5.4.0 version: 5.6.2 @@ -136,6 +148,9 @@ importers: yaml: specifier: ^2.7.0 version: 2.8.3 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@types/node': specifier: ^25.2.3 @@ -261,6 +276,8 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/notifier-macos: {} + packages/plugins/agent-aider: dependencies: '@aoagents/ao-core': @@ -325,6 +342,28 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugins/agent-grok: + dependencies: + '@aoagents/ao-core': + specifier: workspace:* + version: link:../../core + which: + specifier: ^6.0.1 + version: 6.0.1 + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.6.0 + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugins/agent-kimicode: dependencies: '@aoagents/ao-core': @@ -362,9 +401,28 @@ importers: '@aoagents/ao-core': specifier: workspace:* version: link:../../core - composio-core: - specifier: '>=0.5.0' - version: 0.5.39(@ai-sdk/openai@3.0.52(zod@3.25.76))(@cloudflare/workers-types@4.20260410.1)(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(@langchain/openai@1.4.3(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(ws@8.20.0))(ai@6.0.156(zod@3.25.76))(langchain@1.3.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@3.25.76)))(openai@6.34.0(ws@8.20.0)(zod@3.25.76)) + '@composio/core': + specifier: ^0.9.0 + version: 0.9.0(ws@8.20.0)(zod@3.25.76) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.6.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + + packages/plugins/notifier-dashboard: + dependencies: + '@aoagents/ao-core': + specifier: workspace:* + version: link:../../core devDependencies: '@types/node': specifier: ^25.2.3 @@ -655,6 +713,9 @@ importers: '@aoagents/ao-plugin-agent-cursor': specifier: workspace:* version: link:../plugins/agent-cursor + '@aoagents/ao-plugin-agent-grok': + specifier: workspace:* + version: link:../plugins/agent-grok '@aoagents/ao-plugin-agent-kimicode': specifier: workspace:* version: link:../plugins/agent-kimicode @@ -774,28 +835,6 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - '@ai-sdk/gateway@3.0.95': - resolution: {integrity: sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/openai@3.0.52': - resolution: {integrity: sha512-4Rr8NCGmfWTz6DCUvixn9UmyZcMatiHn0zWoMzI3JCUe9R1P/vsPOpCBALKoSzVYOjyJnhtnVIbfUKujcS39uw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/provider-utils@4.0.23': - resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/provider@3.0.8': - resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} - engines: {node: '>=18'} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -898,9 +937,6 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@cfworker/json-schema@4.1.1': - resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -962,13 +998,18 @@ packages: '@clack/prompts@0.9.1': resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} - '@cloudflare/workers-types@4.20260410.1': - resolution: {integrity: sha512-dPZT4aXxwhGHFhWA9iZhWVfFoO8g9exiLzeaS8y43Dw0Sard6Gb3o5LJjReav3ejHbQLHUfGEiZsRPGW8qmgMg==} + '@composio/client@0.1.0-alpha.70': + resolution: {integrity: sha512-bANvjHL9aYwvzcrDL9S2W/uTf6EV3slIpYRL3lAv3k74tTgB1TkYzAWgxxz1OpLK86iDB5xq3RFlritVu23Erg==} - '@composio/mcp@1.0.3-0': - resolution: {integrity: sha512-IpbfST0SSs/CEv+PIf6+EL0feNuJhQyUrOHJLPge8NhyLLrCyVqvujRIPyRUUjy0NDsked/Mm5VpJmYM6OACbg==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - hasBin: true + '@composio/core@0.9.0': + resolution: {integrity: sha512-7gZx1nzHUxGiutPZZQov4va4N69MIbvJlkfZAQd0yG/n0znqntBKbUfF6/GucmBzxwgX58gsS9z512ThVVGOEw==} + peerDependencies: + zod: ^3.25 || ^4 + + '@composio/json-schema-to-zod@0.1.20': + resolution: {integrity: sha512-d4V34itLrUWG/VBh7ciznKcxF/T22MBLHmuEzHoX0zsBOHsUmjYz5qtDh20S2p3FE+HHvLZxpXiv8yfdd4yI+Q==} + peerDependencies: + zod: '>=3.25.76 <5' '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -1360,12 +1401,6 @@ packages: resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} engines: {node: ^20.17.0 || >=22.9.0} - '@hey-api/client-axios@0.2.12': - resolution: {integrity: sha512-lBehVhbnhvm41cFguZuy1FO+4x8NO3Qy/ooL0Jw4bdqTu21n7DmZMPsXEF0gL7/gNdTt4QkJGwaojy+8ExtE8w==} - deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts. - peerDependencies: - axios: ^1.15.0 - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1519,26 +1554,6 @@ packages: cpu: [x64] os: [win32] - '@inquirer/checkbox@2.5.0': - resolution: {integrity: sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA==} - engines: {node: '>=18'} - - '@inquirer/confirm@3.2.0': - resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} - engines: {node: '>=18'} - - '@inquirer/core@9.2.1': - resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} - engines: {node: '>=18'} - - '@inquirer/editor@2.2.0': - resolution: {integrity: sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw==} - engines: {node: '>=18'} - - '@inquirer/expand@2.3.0': - resolution: {integrity: sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw==} - engines: {node: '>=18'} - '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1548,46 +1563,6 @@ packages: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - - '@inquirer/input@2.3.0': - resolution: {integrity: sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==} - engines: {node: '>=18'} - - '@inquirer/number@1.1.0': - resolution: {integrity: sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA==} - engines: {node: '>=18'} - - '@inquirer/password@2.2.0': - resolution: {integrity: sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg==} - engines: {node: '>=18'} - - '@inquirer/prompts@5.5.0': - resolution: {integrity: sha512-BHDeL0catgHdcHbSFFUddNzvx/imzJMft+tWDPwTm3hfu8/tApk1HrooNngB2Mb4qY+KaRWF+iZqoVUPeslEog==} - engines: {node: '>=18'} - - '@inquirer/rawlist@2.3.0': - resolution: {integrity: sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ==} - engines: {node: '>=18'} - - '@inquirer/search@1.1.0': - resolution: {integrity: sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ==} - engines: {node: '>=18'} - - '@inquirer/select@2.5.0': - resolution: {integrity: sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==} - engines: {node: '>=18'} - - '@inquirer/type@1.5.5': - resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} - engines: {node: '>=18'} - - '@inquirer/type@2.0.0': - resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} - engines: {node: '>=18'} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1616,53 +1591,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@langchain/core@1.1.39': - resolution: {integrity: sha512-DP9c7TREy6iA7HnywstmUAsNyJNYTFpRg2yBfQ+6H0l1HnvQzei9GsQ36GeOLxgRaD3vm9K8urCcawSC7yQpCw==} - engines: {node: '>=20'} - - '@langchain/langgraph-checkpoint@1.0.1': - resolution: {integrity: sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw==} - engines: {node: '>=18'} - peerDependencies: - '@langchain/core': ^1.0.1 - - '@langchain/langgraph-sdk@1.8.9': - resolution: {integrity: sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==} - peerDependencies: - '@langchain/core': ^1.1.16 - react: ^18 || ^19 - react-dom: ^18 || ^19 - svelte: ^4.0.0 || ^5.0.0 - vue: ^3.0.0 - peerDependenciesMeta: - '@langchain/core': - optional: true - react: - optional: true - react-dom: - optional: true - svelte: - optional: true - vue: - optional: true - - '@langchain/langgraph@1.2.9': - resolution: {integrity: sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==} - engines: {node: '>=18'} - peerDependencies: - '@langchain/core': ^1.1.40 - zod: ^3.25.32 || ^4.2.0 - zod-to-json-schema: ^3.x - peerDependenciesMeta: - zod-to-json-schema: - optional: true - - '@langchain/openai@1.4.3': - resolution: {integrity: sha512-psf/e06nJ9YFXG67VVaKw6dAEeHLSc6wSbB3+sXpe6zEdpvC7+fyiEIxY6HntBkBU/uyqdeSt8Uu5WL+pQD+vg==} - engines: {node: '>=20'} - peerDependencies: - '@langchain/core': ^1.1.39 - '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -2061,15 +1989,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/mute-stream@0.0.4': - resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@22.19.17': - resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} - '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -2081,8 +2003,8 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - '@types/wrap-ansi@3.0.0': - resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@types/which@3.0.4': + resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2146,10 +2068,6 @@ packages: resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vercel/oidc@3.1.0': - resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} - engines: {node: '>= 20'} - '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2263,12 +2181,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ai@6.0.156: - resolution: {integrity: sha512-uyi/5LYbugHQxZsR2PeAFOZEL4WqKkzZw4pv0nQvvdgxgVOsM7snOmGrYkp5fShxH/vnd08SXvHCVTX7oUW7xQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -2276,10 +2188,6 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2330,9 +2238,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2393,10 +2298,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} @@ -2416,9 +2317,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -2437,18 +2335,10 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} - cli-progress@3.12.0: - resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} - engines: {node: '>=4'} - cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2467,10 +2357,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -2479,19 +2365,6 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - composio-core@0.5.39: - resolution: {integrity: sha512-7BeSFlfRzr1cbIfGYJW4jQ3BHwaObOaFKiRJIFuWOmvOrTABl1hbxGkWPA3C+uFw9CFXbZhrLWNyD7lhYy2Scg==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - hasBin: true - peerDependencies: - '@ai-sdk/openai': '>=0.0.36' - '@cloudflare/workers-types': '>=4.20240718.0' - '@langchain/core': '>=0.2.18' - '@langchain/openai': '>=0.2.5' - ai: '>=3.2.22' - langchain: '>=0.2.11' - openai: '>=4.50.0' - concurrently@9.2.1: resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} @@ -2530,10 +2403,6 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2552,10 +2421,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - define-lazy-prop@2.0.0: - resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} - engines: {node: '>=8'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -2722,16 +2587,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - - eventsource-parser@3.0.7: - resolution: {integrity: sha512-zwxwiQqexizSXFZV13zMiEtW1E3lv7RlUv+1f5FBiR4x7wFhEjm3aFTyYkZQWzyN08WnPdox015GoRH5D/E5YA==} - engines: {node: '>=18.0.0'} - expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -2746,10 +2601,6 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2805,15 +2656,6 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2951,10 +2793,6 @@ packages: engines: {node: '>=18'} hasBin: true - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2988,10 +2826,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inquirer@10.2.2: - resolution: {integrity: sha512-tyao/4Vo36XnUItZ7DnUXX4f1jVao2mSrleV/5IPtW/XAEA26hRVsbc68nuTEKWcr5vMP/1mVoT2O7u8H4v1Vg==} - engines: {node: '>=18'} - ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -3000,11 +2834,6 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3021,10 +2850,6 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} - is-network-error@1.3.1: - resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} - engines: {node: '>=16'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -3052,10 +2877,6 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3086,9 +2907,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - js-tiktoken@1.0.21: - resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3126,9 +2944,6 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3143,32 +2958,6 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - langchain@1.3.1: - resolution: {integrity: sha512-tJu8Ibf3NAuDW8pMT7VIBCc96m8TymjSMqRBpQawlG8zeTGPZK5TKA5gBYRNZvrz0YkJA57O2rcjP+mIxrS0+g==} - engines: {node: '>=20'} - peerDependencies: - '@langchain/core': ^1.1.39 - - langsmith@0.5.20: - resolution: {integrity: sha512-ULhLM8RswvQDXufLtNtvclHrWCBx8Cb5UPI6lAZC+8Dq59iHsVPz/3Ac9khWNm1VIvChRsuykixD/WrmzuuA3Q==} - peerDependencies: - '@opentelemetry/api': '*' - '@opentelemetry/exporter-trace-otlp-proto': '*' - '@opentelemetry/sdk-trace-base': '*' - openai: '*' - ws: '>=7' - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@opentelemetry/exporter-trace-otlp-proto': - optional: true - '@opentelemetry/sdk-trace-base': - optional: true - openai: - optional: true - ws: - optional: true - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3381,14 +3170,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mustache@4.2.0: - resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} - hasBin: true - - mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3467,10 +3248,6 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - open@8.4.2: - resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} - engines: {node: '>=12'} - openai@6.34.0: resolution: {integrity: sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==} hasBin: true @@ -3502,10 +3279,6 @@ packages: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} - p-finally@1.0.0: - resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} - engines: {node: '>=4'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -3530,26 +3303,6 @@ packages: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} - p-queue@6.6.2: - resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} - engines: {node: '>=8'} - - p-queue@9.1.2: - resolution: {integrity: sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==} - engines: {node: '>=20'} - - p-retry@7.1.1: - resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} - engines: {node: '>=20'} - - p-timeout@3.2.0: - resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} - engines: {node: '>=8'} - - p-timeout@7.0.1: - resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} - engines: {node: '>=20'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -3574,14 +3327,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-root-regex@0.1.2: - resolution: {integrity: sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==} - engines: {node: '>=0.10.0'} - - path-root@0.1.1: - resolution: {integrity: sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==} - engines: {node: '>=0.10.0'} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -3662,10 +3407,6 @@ packages: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3673,8 +3414,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pusher-js@8.4.0-rc2: - resolution: {integrity: sha512-d87GjOEEl9QgO5BWmViSqW0LOzPvybvX6WA9zLUstNdB57jVJuR27zHkRnrav2a3+zAMlHbP2Og8wug+rG8T+g==} + pusher-js@8.5.0: + resolution: {integrity: sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==} quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3722,10 +3463,6 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - resolve-package-path@4.0.3: - resolution: {integrity: sha512-SRpNAPW4kewOaNUt8VPqhJ0UMxawMwzJD8V7m1cJfdSTK9ieZwS6K7Dabsm4bmLFM96Z5Y/UznrpG5kt1im8yA==} - engines: {node: '>= 12'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3758,10 +3495,6 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4001,10 +3734,6 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tmp@0.2.4: - resolution: {integrity: sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==} - engines: {node: '>=14.14'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4049,10 +3778,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - typescript-eslint@8.58.1: resolution: {integrity: sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4065,9 +3790,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -4087,18 +3809,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - hasBin: true - - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - - uuid@13.0.0: - resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} - hasBin: true - vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4298,10 +4008,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4375,10 +4081,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -4391,30 +4093,6 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@ai-sdk/gateway@3.0.95(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - '@vercel/oidc': 3.1.0 - zod: 3.25.76 - - '@ai-sdk/openai@3.0.52(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/provider-utils@4.0.23(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.7 - zod: 3.25.76 - - '@ai-sdk/provider@3.0.8': - dependencies: - json-schema: 0.4.0 - '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -4546,8 +4224,6 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@cfworker/json-schema@4.1.1': {} - '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -4702,9 +4378,25 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@cloudflare/workers-types@4.20260410.1': {} + '@composio/client@0.1.0-alpha.70': {} - '@composio/mcp@1.0.3-0': {} + '@composio/core@0.9.0(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@composio/client': 0.1.0-alpha.70 + '@composio/json-schema-to-zod': 0.1.20(zod@3.25.76) + '@types/json-schema': 7.0.15 + chalk: 4.1.2 + openai: 6.34.0(ws@8.20.0)(zod@3.25.76) + pusher-js: 8.5.0 + semver: 7.7.4 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - ws + + '@composio/json-schema-to-zod@0.1.20(zod@3.25.76)': + dependencies: + zod: 3.25.76 '@csstools/color-helpers@5.1.0': {} @@ -4925,10 +4617,6 @@ snapshots: '@gar/promise-retry@1.0.3': {} - '@hey-api/client-axios@0.2.12(axios@1.15.0)': - dependencies: - axios: 1.15.0 - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -5037,46 +4725,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/checkbox@2.5.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - - '@inquirer/confirm@3.2.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - - '@inquirer/core@9.2.1': - dependencies: - '@inquirer/figures': 1.0.15 - '@inquirer/type': 2.0.0 - '@types/mute-stream': 0.0.4 - '@types/node': 22.19.17 - '@types/wrap-ansi': 3.0.0 - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 1.0.0 - signal-exit: 4.1.0 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - - '@inquirer/editor@2.2.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - external-editor: 3.1.0 - - '@inquirer/expand@2.3.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - yoctocolors-cjs: 2.1.3 - '@inquirer/external-editor@1.0.3(@types/node@25.6.0)': dependencies: chardet: 2.1.1 @@ -5084,66 +4732,6 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 - '@inquirer/figures@1.0.15': {} - - '@inquirer/input@2.3.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - - '@inquirer/number@1.1.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - - '@inquirer/password@2.2.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - - '@inquirer/prompts@5.5.0': - dependencies: - '@inquirer/checkbox': 2.5.0 - '@inquirer/confirm': 3.2.0 - '@inquirer/editor': 2.2.0 - '@inquirer/expand': 2.3.0 - '@inquirer/input': 2.3.0 - '@inquirer/number': 1.1.0 - '@inquirer/password': 2.2.0 - '@inquirer/rawlist': 2.3.0 - '@inquirer/search': 1.1.0 - '@inquirer/select': 2.5.0 - - '@inquirer/rawlist@2.3.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - yoctocolors-cjs: 2.1.3 - - '@inquirer/search@1.1.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 1.5.5 - yoctocolors-cjs: 2.1.3 - - '@inquirer/select@2.5.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - - '@inquirer/type@1.5.5': - dependencies: - mute-stream: 1.0.0 - - '@inquirer/type@2.0.0': - dependencies: - mute-stream: 1.0.0 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5178,67 +4766,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0)': - dependencies: - '@cfworker/json-schema': 4.1.1 - '@standard-schema/spec': 1.1.0 - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.21 - langsmith: 0.5.20(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - mustache: 4.2.0 - p-queue: 6.6.2 - uuid: 11.1.0 - zod: 3.25.76 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - openai - - ws - - '@langchain/langgraph-checkpoint@1.0.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))': - dependencies: - '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - uuid: 10.0.0 - - '@langchain/langgraph-sdk@1.8.9(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@types/json-schema': 7.0.15 - p-queue: 9.1.2 - p-retry: 7.1.1 - uuid: 13.0.0 - optionalDependencies: - '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - '@langchain/langgraph@1.2.9(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': - dependencies: - '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0)) - '@langchain/langgraph-sdk': 1.8.9(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@standard-schema/spec': 1.1.0 - uuid: 10.0.0 - zod: 3.25.76 - optionalDependencies: - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - react - - react-dom - - svelte - - vue - - '@langchain/openai@1.4.3(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(ws@8.20.0)': - dependencies: - '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - js-tiktoken: 1.0.21 - openai: 6.34.0(ws@8.20.0)(zod@3.25.76) - zod: 3.25.76 - transitivePeerDependencies: - - ws - '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -5320,7 +4847,8 @@ snapshots: '@npmcli/redact@4.0.0': {} - '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.0': + optional: true '@pkgjs/parseargs@0.11.0': optional: true @@ -5566,16 +5094,8 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/mute-stream@0.0.4': - dependencies: - '@types/node': 25.6.0 - '@types/node@12.20.55': {} - '@types/node@22.19.17': - dependencies: - undici-types: 6.21.0 - '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -5588,7 +5108,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/wrap-ansi@3.0.0': {} + '@types/which@3.0.4': {} '@types/ws@8.18.1': dependencies: @@ -5685,8 +5205,6 @@ snapshots: '@typescript-eslint/types': 8.58.1 eslint-visitor-keys: 5.0.1 - '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -5835,14 +5353,6 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.156(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 3.0.95(zod@3.25.76) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -5852,10 +5362,6 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -5898,19 +5404,12 @@ snapshots: asynckit@0.4.0: {} - axios@1.15.0: - dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.5 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - balanced-match@1.0.2: {} balanced-match@4.0.4: {} - base64-js@1.5.1: {} + base64-js@1.5.1: + optional: true baseline-browser-mapping@2.10.17: {} @@ -5982,8 +5481,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - camelcase@6.3.0: {} - caniuse-lite@1.0.30001787: {} chai@5.3.3: @@ -6003,8 +5500,6 @@ snapshots: chalk@5.6.2: {} - chardet@0.7.0: {} - chardet@2.1.1: {} check-error@2.1.3: {} @@ -6018,14 +5513,8 @@ snapshots: dependencies: restore-cursor: 5.1.0 - cli-progress@3.12.0: - dependencies: - string-width: 4.2.3 - cli-spinners@2.9.2: {} - cli-width@4.1.0: {} - client-only@0.0.1: {} cliui@8.0.1: @@ -6044,37 +5533,10 @@ snapshots: dependencies: delayed-stream: 1.0.0 - commander@12.1.0: {} - commander@13.1.0: {} commander@7.2.0: {} - composio-core@0.5.39(@ai-sdk/openai@3.0.52(zod@3.25.76))(@cloudflare/workers-types@4.20260410.1)(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(@langchain/openai@1.4.3(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(ws@8.20.0))(ai@6.0.156(zod@3.25.76))(langchain@1.3.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@3.25.76)))(openai@6.34.0(ws@8.20.0)(zod@3.25.76)): - dependencies: - '@ai-sdk/openai': 3.0.52(zod@3.25.76) - '@cloudflare/workers-types': 4.20260410.1 - '@composio/mcp': 1.0.3-0 - '@hey-api/client-axios': 0.2.12(axios@1.15.0) - '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - '@langchain/openai': 1.4.3(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(ws@8.20.0) - ai: 6.0.156(zod@3.25.76) - axios: 1.15.0 - chalk: 4.1.2 - cli-progress: 3.12.0 - commander: 12.1.0 - inquirer: 10.2.2 - langchain: 1.3.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@3.25.76)) - open: 8.4.2 - openai: 6.34.0(ws@8.20.0)(zod@3.25.76) - pusher-js: 8.4.0-rc2 - resolve-package-path: 4.0.3 - uuid: 10.0.0 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - debug - concurrently@9.2.1: dependencies: chalk: 4.1.2 @@ -6112,8 +5574,6 @@ snapshots: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - decimal.js@10.6.0: {} decompress-response@6.0.0: @@ -6128,8 +5588,6 @@ snapshots: deep-is@0.1.4: {} - define-lazy-prop@2.0.0: {} - delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -6342,12 +5800,6 @@ snapshots: esutils@2.0.3: {} - eventemitter3@4.0.7: {} - - eventemitter3@5.0.4: {} - - eventsource-parser@3.0.7: {} - expand-template@2.0.3: optional: true @@ -6357,12 +5809,6 @@ snapshots: extendable-error@0.1.7: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.2.4 - fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -6421,8 +5867,6 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.16.0: {} - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -6572,10 +6016,6 @@ snapshots: husky@9.1.7: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -6601,25 +6041,12 @@ snapshots: ini@1.3.8: optional: true - inquirer@10.2.2: - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/prompts': 5.5.0 - '@inquirer/type': 1.5.5 - '@types/mute-stream': 0.0.4 - ansi-escapes: 4.3.2 - mute-stream: 1.0.0 - run-async: 3.0.0 - rxjs: 7.8.2 - ip-address@10.1.0: {} is-core-module@2.16.1: dependencies: hasown: 2.0.2 - is-docker@2.2.1: {} - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6630,8 +6057,6 @@ snapshots: is-interactive@2.0.0: {} - is-network-error@1.3.1: {} - is-number@7.0.0: {} is-plain-object@5.0.0: {} @@ -6648,10 +6073,6 @@ snapshots: is-windows@1.0.2: {} - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 - isexe@2.0.0: {} isexe@4.0.0: {} @@ -6685,10 +6106,6 @@ snapshots: jiti@2.6.1: {} - js-tiktoken@1.0.21: - dependencies: - base64-js: 1.5.1 - js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -6738,8 +6155,6 @@ snapshots: json-schema-traverse@0.4.1: {} - json-schema@0.4.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -6752,35 +6167,6 @@ snapshots: dependencies: json-buffer: 3.0.1 - langchain@1.3.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@3.25.76)): - dependencies: - '@langchain/core': 1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - '@langchain/langgraph': 1.2.9(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76) - '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.39(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0)) - langsmith: 0.5.20(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0) - uuid: 11.1.0 - zod: 3.25.76 - transitivePeerDependencies: - - '@opentelemetry/api' - - '@opentelemetry/exporter-trace-otlp-proto' - - '@opentelemetry/sdk-trace-base' - - openai - - react - - react-dom - - svelte - - vue - - ws - - zod-to-json-schema - - langsmith@0.5.20(@opentelemetry/api@1.9.0)(openai@6.34.0(ws@8.20.0)(zod@3.25.76))(ws@8.20.0): - dependencies: - p-queue: 6.6.2 - uuid: 10.0.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - openai: 6.34.0(ws@8.20.0)(zod@3.25.76) - ws: 8.20.0 - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -6975,10 +6361,6 @@ snapshots: ms@2.1.3: {} - mustache@4.2.0: {} - - mute-stream@1.0.0: {} - nanoid@3.3.11: {} napi-build-utils@2.0.0: @@ -7062,12 +6444,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - open@8.4.2: - dependencies: - define-lazy-prop: 2.0.0 - is-docker: 2.2.1 - is-wsl: 2.2.0 - openai@6.34.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0 @@ -7102,8 +6478,6 @@ snapshots: dependencies: p-map: 2.1.0 - p-finally@1.0.0: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -7124,26 +6498,6 @@ snapshots: p-map@7.0.4: {} - p-queue@6.6.2: - dependencies: - eventemitter3: 4.0.7 - p-timeout: 3.2.0 - - p-queue@9.1.2: - dependencies: - eventemitter3: 5.0.4 - p-timeout: 7.0.1 - - p-retry@7.1.1: - dependencies: - is-network-error: 1.3.1 - - p-timeout@3.2.0: - dependencies: - p-finally: 1.0.0 - - p-timeout@7.0.1: {} - p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -7162,12 +6516,6 @@ snapshots: path-parse@1.0.7: {} - path-root-regex@0.1.2: {} - - path-root@0.1.1: - dependencies: - path-root-regex: 0.1.2 - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -7242,8 +6590,6 @@ snapshots: proc-log@6.1.0: {} - proxy-from-env@2.1.0: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -7252,7 +6598,7 @@ snapshots: punycode@2.3.1: {} - pusher-js@8.4.0-rc2: + pusher-js@8.5.0: dependencies: tweetnacl: 1.0.3 @@ -7302,10 +6648,6 @@ snapshots: resolve-from@5.0.0: {} - resolve-package-path@4.0.3: - dependencies: - path-root: 0.1.1 - resolve-pkg-maps@1.0.0: {} resolve@1.22.12: @@ -7362,8 +6704,6 @@ snapshots: rrweb-cssom@0.8.0: {} - run-async@3.0.0: {} - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7614,8 +6954,6 @@ snapshots: dependencies: tldts-core: 6.1.86 - tmp@0.2.4: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7656,8 +6994,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.21.3: {} - typescript-eslint@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) @@ -7671,8 +7007,6 @@ snapshots: typescript@5.9.3: {} - undici-types@6.21.0: {} - undici-types@7.19.2: {} universalify@0.1.2: {} @@ -7690,12 +7024,6 @@ snapshots: util-deprecate@1.0.2: optional: true - uuid@10.0.0: {} - - uuid@11.1.0: {} - - uuid@13.0.0: {} - vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 @@ -7872,12 +7200,6 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -7925,8 +7247,6 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} - zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/schema/config.schema.json b/schema/config.schema.json index a5b78af6e..cf4dd56cc 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -41,6 +41,9 @@ "lifecycle": { "$ref": "#/$defs/lifecycleConfig" }, + "observability": { + "$ref": "#/$defs/observabilityConfig" + }, "defaults": { "$ref": "#/$defs/defaultPlugins" }, @@ -513,6 +516,21 @@ } } }, + "observabilityConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "logLevel": { + "type": "string", + "enum": ["debug", "info", "warn", "error"], + "default": "warn" + }, + "stderr": { + "type": "boolean", + "default": false + } + } + }, "notificationRouting": { "type": "object", "default": {}, diff --git a/website/content/docs/cli.mdx b/website/content/docs/cli.mdx index 4176b1c25..b825c57ed 100644 --- a/website/content/docs/cli.mdx +++ b/website/content/docs/cli.mdx @@ -25,8 +25,9 @@ ao ├── update Check for updates and upgrade AO ├── init (deprecated — use ao start) ├── config-help Print the config schema and a guide +├── notify test ├── session ls, attach, kill, cleanup, restore, claim-pr, remap -├── setup openclaw +├── setup dashboard, desktop, webhook, slack, discord, composio, openclaw └── plugin list, search, create, install, update, uninstall ``` @@ -184,6 +185,24 @@ Behavior depends on how AO was installed — git, npm-global, pnpm-global, or un No flags. +## Notify subcommands + +### `ao notify test` + +> Send a manual demo notification without spawning sessions + +| Flag | Default | Purpose | +|---|---|---| +| `--template ` | `basic` | Demo template to send | +| `--to ` | — | Comma-separated notifier refs to target | +| `--all` | — | Send to all configured, default, and routed notifier refs | +| `--route ` | — | Send through `urgent`, `action`, `warning`, or `info` routing | +| `--actions` | — | Include demo actions when supported | +| `--message ` | — | Override the notification message | +| `--dry-run` | — | Resolve targets without sending | +| `--json` | — | Print structured JSON output | +| `--sink [port]` | — | Add a local webhook target named `sink` | + ## Session subcommands ### `ao session ls` @@ -245,6 +264,25 @@ No flags. Relaunches the agent inside the same worktree and rewires session meta ## Setup subcommands +Notifier setup commands that write AO config support `--routing-preset `. +Valid presets are `urgent-only`, `urgent-action`, and `all`. In interactive +mode, AO asks which notification priorities the notifier should receive before +writing `notificationRouting`. + +| Command | Purpose | +|---|---| +| `ao setup dashboard` | Configure dashboard notification retention and routing | +| `ao setup desktop` | Configure native desktop notifications | +| `ao setup webhook` | Configure a generic HTTP webhook | +| `ao setup slack` | Configure a Slack incoming webhook | +| `ao setup discord` | Configure a Discord incoming webhook | +| `ao setup composio` | Open the interactive Composio setup hub | +| `ao setup composio-slack` | Configure Slack through Composio | +| `ao setup composio-discord` | Configure Discord webhook mode through Composio | +| `ao setup composio-discord-bot` | Configure Discord bot mode through Composio | +| `ao setup composio-mail` | Configure Gmail through Composio | +| `ao setup openclaw` | Configure OpenClaw gateway notifications | + ### `ao setup openclaw` > Connect AO notifications to an OpenClaw gateway @@ -252,11 +290,16 @@ No flags. Relaunches the agent inside the same worktree and rewires session meta | Flag | Default | Purpose | |---|---|---| | `--url ` | — | OpenClaw webhook URL | -| `--token ` | — | OpenClaw hooks auth token | +| `--token ` | — | Remote/manual fallback; local setup should read `hooks.token` from OpenClaw config | +| `--openclaw-config-path ` | `~/.openclaw/openclaw.json` | OpenClaw config path that contains `hooks.token` | | `--routing-preset ` | — | `urgent-only` · `urgent-action` · `all` | +| `--refresh` | — | Reuse existing values and rewrite AO config | +| `--no-test` | — | Skip the setup token probe | +| `--force` | — | Replace a conflicting `notifiers.openclaw` entry | +| `--status` | — | Show OpenClaw config, gateway state, and token probe status | | `--non-interactive` | — | Skip prompts — auto-detect OpenClaw on localhost | -Interactive mode (TTY) uses `@clack/prompts`. Writes to `agent-orchestrator.yaml`, `~/.openclaw/openclaw.json`, and your shell profile. The token lands in YAML as `${OPENCLAW_HOOKS_TOKEN}` — never committed raw. +Interactive mode (TTY) uses `@clack/prompts` with review/change steps for the gateway URL, OpenClaw config path, and routing preset. OpenClaw owns the token in `hooks.token`; AO does not generate it or write shell-profile exports. ## Plugin subcommands @@ -296,7 +339,8 @@ No flags. | Flag | Default | Purpose | |---|---|---| | `--url ` | — | Passed to `ao setup openclaw` when installing `notifier-openclaw` | -| `--token ` | — | Same — passed through for the openclaw handshake | +| `--token ` | — | Remote/manual OpenClaw token fallback | +| `--openclaw-config-path ` | `~/.openclaw/openclaw.json` | OpenClaw config path that contains `hooks.token` | `` is a marketplace id, npm package name, or local path. diff --git a/website/content/docs/plugins/index.mdx b/website/content/docs/plugins/index.mdx index e3251b832..fe9e5859b 100644 --- a/website/content/docs/plugins/index.mdx +++ b/website/content/docs/plugins/index.mdx @@ -60,6 +60,7 @@ AO has **eight plugin slots**. Only one plugin per slot is active at a time, and ## Notifiers + diff --git a/website/content/docs/plugins/notifiers/composio.mdx b/website/content/docs/plugins/notifiers/composio.mdx index 9364ffcd9..72f62f84a 100644 --- a/website/content/docs/plugins/notifiers/composio.mdx +++ b/website/content/docs/plugins/notifiers/composio.mdx @@ -5,48 +5,156 @@ description: Route notifications through the Composio toolkit — Slack, Discord
- Slot: notifier · Name: composio + + Slot: notifier · Name: composio +
-Uses the [Composio toolkit](https://composio.dev) to deliver notifications. Handy when you already have Composio set up for other agent tooling — a single credential covers Slack, Discord, and Gmail. +Uses the [Composio toolkit](https://composio.dev) to deliver notifications. Handy when you already have Composio set up for other agent tooling — a single credential covers Slack, Discord, and Gmail. Discord supports a low-friction webhook mode and an advanced bot mode. ## Setup -Install the Composio core package alongside AO: +Run the interactive Composio setup hub: ```bash -npm install -g composio-core +ao setup composio ``` -Export your Composio API key: +The hub lets you choose Slack, Discord webhook, Discord bot, or Gmail. AO asks for the Composio API key, `userId`, required target details, which notification priorities the notifier should receive, then reviews the config before writing. When you run `ao setup composio`, the selected app is written to the canonical `notifiers.composio` target. You can skip the app picker with `--slack`, `--discord-webhook`, `--discord-bot`, or `--gmail`. Discord webhook mode does not need a Composio connected account; Discord bot mode uses a bot token once to create a connected account and stores only the resulting `connectedAccountId`. Gmail mode uses an existing Gmail connected account, or creates a Composio connect link from an existing Gmail auth config. + +Dedicated commands use the same guided setup but write app-specific targets: ```bash -export COMPOSIO_API_KEY=cp_... +ao setup composio-slack # writes notifiers.composio-slack +ao setup composio-discord # writes notifiers.composio-discord +ao setup composio-discord-bot # writes notifiers.composio-discord-bot +ao setup composio-mail # writes notifiers.composio-mail ``` +You can also run the scriptable Slack setup directly: + +```bash +export COMPOSIO_API_KEY=ak_... +ao setup composio-slack --user-id aoagent --channel "#agents" --non-interactive +``` + +All Composio setup commands accept `--routing-preset urgent-only`, +`--routing-preset urgent-action`, or `--routing-preset all`. + +If you already know the Slack connected account id, pass it directly: + +```bash +ao setup composio-slack --connected-account-id ca_... --non-interactive +``` + +Run the Discord webhook setup when you only need channel notifications: + +```bash +export COMPOSIO_API_KEY=ak_... +ao setup composio-discord --webhook-url "https://discord.com/api/webhooks/..." --non-interactive +``` + +Run the Discord bot setup when you need a bot identity and channel-id based delivery: + +```bash +export COMPOSIO_API_KEY=ak_... +ao setup composio-discord-bot --channel-id "1234567890" --bot-token "$DISCORD_BOT_TOKEN" --non-interactive +``` + +The bot token is used once to create a Composio connected account. AO writes only the resulting `connectedAccountId`. + +Run the Gmail setup when you want AO notifications by email: + +```bash +export COMPOSIO_API_KEY=ak_... +ao setup composio-mail --email-to alerts@example.com --non-interactive +``` + +Connect Gmail in Composio first, or choose Gmail in `ao setup composio` and generate a connect link from an existing Gmail auth config. AO does not create Gmail OAuth/auth configs. The setup command finds an active Gmail connected account for the configured `userId` and writes its `connectedAccountId`. If multiple Gmail accounts are available, pass the one AO should use: + +```bash +ao setup composio-mail --email-to alerts@example.com --connected-account-id ca_... +``` + +You can also ask AO to print a Composio Gmail connect URL: + +```bash +ao setup composio-mail --email-to alerts@example.com --connect +``` + +AO uses an existing Composio Gmail auth config for that link. If you need a specific custom Gmail auth config, pass it explicitly: + +```bash +ao setup composio-mail --email-to alerts@example.com --connect --auth-config-id ac_... +``` + +If Google blocks a Gmail connection, fix the Gmail auth config in Composio, then rerun `ao setup composio-mail`. + ## Use ```yaml title="agent-orchestrator.yaml" -notifier: - - type: composio - defaultApp: slack # slack | discord | gmail - channelName: "#agents" # Slack / Discord - channelId: "..." # optional, used if channelName is ambiguous - emailTo: alerts@example.com # required when defaultApp=gmail - composioApiKey: ${CUSTOM} # optional override; otherwise uses env +notifiers: + composio: + plugin: composio + defaultApp: slack # slack | discord | gmail + userId: aoagent # Composio user id + connectedAccountId: ca_... # preferred when available + channelName: "#agents" # Slack + emailTo: alerts@example.com # required when defaultApp=gmail + composioApiKey: ak_... # optional override; otherwise uses env + + composio-discord: + plugin: composio + defaultApp: discord + mode: webhook + webhookUrl: https://discord.com/api/webhooks/... + userId: aoagent + + composio-discord-bot: + plugin: composio + defaultApp: discord + mode: bot + channelId: "1234567890" + userId: aoagent + connectedAccountId: ca_... + + composio-mail: + plugin: composio + defaultApp: gmail + emailTo: alerts@example.com + userId: aoagent + connectedAccountId: ca_... + + composio-slack: + plugin: composio + defaultApp: slack + userId: aoagent + connectedAccountId: ca_... + channelName: "#agents" ``` ## Config -| Key | Required | Default | What it does | -|---|---|---|---| -| `defaultApp` | ✓ | `slack` | Which Composio app to target: `slack`, `discord`, or `gmail` | -| `channelName` | for Slack/Discord | — | Channel name (Composio resolves it) | -| `channelId` | optional | — | Explicit channel id when the name is ambiguous | -| `emailTo` | ✓ when `gmail` | — | Recipient address | -| `composioApiKey` | optional | env `COMPOSIO_API_KEY` | Override the API key from config | +| Key | Required | Default | What it does | +| -------------------- | --------------- | ---------------------- | -------------------------------------------------------------------- | +| `defaultApp` | ✓ | `slack` | Which Composio app to target: `slack`, `discord`, or `gmail` | +| `mode` | Discord only | auto | `webhook` uses Discord webhooks; `bot` uses bot channel messages | +| `userId` | optional | `aoagent` | Composio user id for connected-account lookup | +| `entityId` | optional | — | Backward-compatible alias for `userId` | +| `authConfigId` | setup only | — | Existing Composio Gmail auth config used by `--connect` | +| `connectedAccountId` | Slack/Gmail/bot | — | Specific connected account to use; not used for Discord webhook mode | +| `channelName` | for Slack | — | Slack channel name | +| `channelId` | Discord bot | — | Discord channel id for bot mode | +| `webhookUrl` | Discord webhook | — | Discord webhook URL for webhook mode | +| `emailTo` | ✓ when `gmail` | — | Recipient address | +| `composioApiKey` | optional | env `COMPOSIO_API_KEY` | Override the API key from config | +| `toolVersion` | optional | app default | Tool version passed to `@composio/core` | +| `toolVersions` | optional | — | App-specific tool versions, for example `{ slack: "20260508_00" }` | + +Slack defaults to `20260508_00`; Discord defaults to `20260429_01`; Gmail defaults to `20260506_01`. Discord uses Composio's `discordbot` toolkit for both webhook and bot modes. Webhook mode calls `DISCORDBOT_EXECUTE_WEBHOOK` with the Discord webhook id/token from `webhookUrl`, so no Composio Discord connect link is required. +If `mode` is omitted for Discord, AO uses `webhook` when `webhookUrl` is present and `bot` otherwise. ## When to use diff --git a/website/content/docs/plugins/notifiers/dashboard.mdx b/website/content/docs/plugins/notifiers/dashboard.mdx new file mode 100644 index 000000000..1bfbd407b --- /dev/null +++ b/website/content/docs/plugins/notifiers/dashboard.mdx @@ -0,0 +1,36 @@ +--- +title: Dashboard notifier +description: Retain AO notifications in the local web dashboard. +--- + +The dashboard notifier records AO notifications in a local JSONL store and streams +the latest retained records to the web dashboard over the existing local mux +WebSocket. + +```yaml +notifiers: + dashboard: + plugin: dashboard + limit: 50 + +notificationRouting: + urgent: [dashboard] + action: [dashboard] +``` + +## Setup + +```bash +ao setup dashboard +ao setup dashboard --limit 100 --routing-preset all +ao setup dashboard --status +``` + +`limit` controls how many recent notifications are retained for browser reloads +and dashboard restarts. The default is `50`. + +## Test + +```bash +ao notify test --to dashboard --template basic +``` diff --git a/website/content/docs/plugins/notifiers/desktop.mdx b/website/content/docs/plugins/notifiers/desktop.mdx index 0ac49746d..ef62d1e2a 100644 --- a/website/content/docs/plugins/notifiers/desktop.mdx +++ b/website/content/docs/plugins/notifiers/desktop.mdx @@ -10,17 +10,37 @@ description: Native macOS / Linux notifications. Silent no-op on Windows. +## Setup + +Run the setup command on macOS: + +```bash +ao setup desktop +``` + +AO asks which desktop backend to use and which notification priorities desktop +should receive: `urgent-only`, `urgent-action`, or `all`. For scriptable setup, +pass `--routing-preset `. + ## Use ```yaml title="agent-orchestrator.yaml" -notifier: - - type: desktop - sound: true # default +notifiers: + desktop: + plugin: desktop + backend: ao-app + dashboardUrl: http://localhost:3000 + +notificationRouting: + urgent: [desktop] + action: [desktop] ``` | Config key | Default | What it does | |---|---|---| -| `sound` | `true` | Play the system notification sound | +| `backend` | `auto` | `ao-app`, `terminal-notifier`, `osascript`, or auto fallback | +| `dashboardUrl` | dashboard port | URL opened from desktop notification actions | +| `appPath` | macOS app install path | Custom AO Notifier.app path | ## How it works diff --git a/website/content/docs/plugins/notifiers/discord.mdx b/website/content/docs/plugins/notifiers/discord.mdx index 042380aa5..d841f906b 100644 --- a/website/content/docs/plugins/notifiers/discord.mdx +++ b/website/content/docs/plugins/notifiers/discord.mdx @@ -5,38 +5,72 @@ description: Discord webhook with rich embeds, retry handling, and thread suppor
- Slot: notifier · Name: discord + + Slot: notifier · Name: discord +
## Setup -1. In Discord, open the channel you want → **Edit Channel → Integrations → Webhooks → New Webhook**. -2. Copy the webhook URL (must contain `discord.com/api/webhooks/`). -3. Add it to AO: +Run the setup command: + +```bash +ao setup discord +``` + +If Discord is already configured, the command asks whether to keep the existing +webhook URL, change it, create a new one, or cancel. If you already have a +Discord webhook URL, paste it when prompted. If not, the command shows Discord +links and waits while you create one. From the setup steps screen you can paste +the URL, show the steps again, go back to the previous options, or cancel. The +change-webhook path also lets you paste a new URL, view creation steps, go back, +or cancel. + +1. Open **https://discord.com/app**. +2. Open the target server and channel. +3. Open **Edit Channel → Integrations → Webhooks**. +4. Create a new webhook. +5. Copy the webhook URL and paste it into AO. + +The command sends a test message before writing AO config. +Before saving, AO also asks which notification priorities Discord should receive: +`urgent-only`, `urgent-action`, or `all`. For scriptable setup, pass +`--routing-preset `. + +Useful setup checks: + +```bash +ao setup discord --status +ao setup discord --refresh +``` ```yaml title="agent-orchestrator.yaml" -notifier: - - type: discord - webhookUrl: ${DISCORD_WEBHOOK_URL} - username: "AO" # optional - avatarUrl: https://...png # optional - threadId: "1234567890" # optional — post into a thread - retries: 3 # default - retryDelayMs: 1000 # default +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/... + username: "Agent Orchestrator" + avatarUrl: https://...png # optional + threadId: "1234567890" # optional; post into a thread + retries: 2 + retryDelayMs: 1000 ``` ## Config -| Key | Required | Default | What it does | -|---|---|---|---| -| `webhookUrl` | ✓ | — | Full `discord.com/api/webhooks/...` URL | -| `username` | optional | — | Override webhook default username per message | -| `avatarUrl` | optional | — | Override webhook default avatar | -| `threadId` | optional | — | Post into an existing thread | -| `retries` | optional | `3` | Retry count on 5xx / network errors | -| `retryDelayMs` | optional | `1000` | Base retry delay (exponential backoff) | +| Key | Required | Default | What it does | +| -------------- | -------- | -------------------- | ------------------------------------------------------------------- | +| `webhookUrl` | ✓ | — | Full `discord.com/api/webhooks/...` URL bound to the target channel | +| `username` | optional | `Agent Orchestrator` | Override webhook default username per message | +| `avatarUrl` | optional | — | Override webhook default avatar with an image URL | +| `threadId` | optional | — | Post into an existing thread | +| `retries` | optional | `2` | Retry count on 429 / 5xx / network errors | +| `retryDelayMs` | optional | `1000` | Base retry delay (exponential backoff) | + +Discord webhook URLs are tied to the channel selected in Discord. To send to a +different channel, create or copy a webhook URL from that channel. ## Rich embeds @@ -54,5 +88,6 @@ Handles Discord's `Retry-After` header on 429 responses — waits the requested ## Testing ```bash -ao doctor --test-notify +ao notify test --to discord --template basic +ao notify test --to discord --template merge-ready --actions ``` diff --git a/website/content/docs/plugins/notifiers/index.mdx b/website/content/docs/plugins/notifiers/index.mdx index b581e314a..f05d58e05 100644 --- a/website/content/docs/plugins/notifiers/index.mdx +++ b/website/content/docs/plugins/notifiers/index.mdx @@ -1,11 +1,12 @@ --- title: Notifiers overview -description: Who gets pinged when an agent needs you. Six notifiers ship; pick one or stack several. +description: Who gets pinged when an agent needs you. Seven notifiers ship; pick one or stack several. --- Notifiers deliver AO events — session stuck, PR opened, review requested, CI failed — to wherever you actually pay attention. + @@ -16,27 +17,42 @@ Notifiers deliver AO events — session stuck, PR opened, review requested, CI f ## Stacking notifiers -Notifier config is a list — pass as many as you want: +Notifier config is a map. Add as many named notifiers as you want, then route +priorities with `notificationRouting`: ```yaml -notifier: - - type: desktop - - type: discord +notifiers: + desktop: + plugin: desktop + discord: + plugin: discord webhookUrl: ${DISCORD_URL} - - type: slack + slack: + plugin: slack webhookUrl: ${SLACK_URL} - channel: "#agents" + +notificationRouting: + urgent: [desktop, discord, slack] + action: [discord, slack] + warning: [slack] + info: [] ``` -Each notifier gets every event. AO doesn't try to dedupe across channels — that's your channel-configuration problem, not ours. +Setup commands can write this routing for you with `--routing-preset +urgent-only`, `urgent-action`, or `all`. AO does not dedupe across channels, so +route only the priorities each destination should receive. ## Testing ```bash -ao doctor --test-notify +ao notify test --to slack --template basic +ao notify test --route urgent +ao notify test --all --dry-run ``` -Sends a dummy notification through each configured notifier. Good way to verify your webhooks work before an agent needs to wake you at 3 AM. +Use `--to` for one target, `--route` for a priority route, or `--all` for every +configured/default/routed notifier. Without `--dry-run`, real providers receive +a real test message. ## What gets notified diff --git a/website/content/docs/plugins/notifiers/meta.json b/website/content/docs/plugins/notifiers/meta.json index a9e98e4ea..609d8a46e 100644 --- a/website/content/docs/plugins/notifiers/meta.json +++ b/website/content/docs/plugins/notifiers/meta.json @@ -1,4 +1,4 @@ { "title": "Notifiers", - "pages": ["desktop", "discord", "slack", "webhook", "composio", "openclaw"] + "pages": ["dashboard", "desktop", "discord", "slack", "webhook", "composio", "openclaw"] } diff --git a/website/content/docs/plugins/notifiers/openclaw.mdx b/website/content/docs/plugins/notifiers/openclaw.mdx index 8f84c0b66..b3f5fc256 100644 --- a/website/content/docs/plugins/notifiers/openclaw.mdx +++ b/website/content/docs/plugins/notifiers/openclaw.mdx @@ -23,13 +23,22 @@ ao setup openclaw It will: 1. Auto-detect OpenClaw on `localhost` (or prompt for the URL). -2. Exchange / generate an auth token. -3. Write `agent-orchestrator.yaml`, `~/.openclaw/openclaw.json`, and a shell-profile export for `OPENCLAW_HOOKS_TOKEN`. +2. Read `hooks.token` from your OpenClaw config. +3. If the token is missing, show where to add/generate it from the OpenClaw side. +4. Write only `agent-orchestrator.yaml`; AO does not generate the token or write shell-profile exports. +5. Let you review and change the URL, OpenClaw config path, or routing preset before saving. Non-interactive variant: ```bash -ao setup openclaw --non-interactive --url http://127.0.0.1:18789/hooks/agent --token +ao setup openclaw --non-interactive --url http://127.0.0.1:18789/hooks/agent --routing-preset urgent-action +``` + +Refresh an existing setup or inspect status: + +```bash +ao setup openclaw --refresh +ao setup openclaw --status ``` ## Use @@ -37,11 +46,25 @@ ao setup openclaw --non-interactive --url http://127.0.0.1:18789/hooks/agent --t After setup, your config looks like: ```yaml title="agent-orchestrator.yaml" -notifier: - - type: openclaw +notifiers: + openclaw: + plugin: openclaw url: http://127.0.0.1:18789/hooks/agent - token: ${OPENCLAW_HOOKS_TOKEN} - wakeMode: next-heartbeat # or `now` + openclawConfigPath: ~/.openclaw/openclaw.json + wakeMode: now +``` + +The OpenClaw config keeps the actual secret: + +```json title="~/.openclaw/openclaw.json" +{ + "hooks": { + "enabled": true, + "token": "", + "allowRequestSessionKey": true, + "allowedSessionKeyPrefixes": ["hook:"] + } +} ``` ## Config @@ -49,7 +72,8 @@ notifier: | Key | Default | What it does | |---|---|---| | `url` | `http://127.0.0.1:18789/hooks/agent` | OpenClaw hook endpoint | -| `token` | — | Auth token. Use `${ENV_VAR}` — never commit the raw value | +| `openclawConfigPath` | `~/.openclaw/openclaw.json` | Local OpenClaw config path that contains `hooks.token` | +| `token` | — | Remote/manual fallback. Avoid for local setups because it stores the secret in AO config | | `name` | — | Identifier shown in OpenClaw | | `sessionKeyPrefix` | — | Prefix for OpenClaw session keys | | `wakeMode` | `next-heartbeat` | `now` = deliver immediately, `next-heartbeat` = batch on next OpenClaw tick | @@ -57,14 +81,18 @@ notifier: | `retries` | `3` | Retry count on transient failures | | `retryDelayMs` | `1000` | Base retry delay | | `healthSummaryPath` | — | Optional local path where OpenClaw writes a summary AO can pick up | -| `configPath` | `~/.openclaw/openclaw.json` | Override OpenClaw config location | ## Token precedence Token is read in this order: -1. `token` in `agent-orchestrator.yaml` (usually `${OPENCLAW_HOOKS_TOKEN}`) -2. Env var `OPENCLAW_HOOKS_TOKEN` -3. `~/.openclaw/openclaw.json` +1. `token` in `agent-orchestrator.yaml` if explicitly configured +2. `hooks.token` from `openclawConfigPath` +3. Env var `OPENCLAW_HOOKS_TOKEN` as a legacy fallback -`ao doctor --test-notify` is the only notifier test that can probe OpenClaw without side effects — other notifiers send a real test message. +`ao setup openclaw --status` probes the OpenClaw gateway and token. To send a +real AO test notification through the configured notifier, run: + +```bash +ao notify test --to openclaw --template basic +``` diff --git a/website/content/docs/plugins/notifiers/slack.mdx b/website/content/docs/plugins/notifiers/slack.mdx index 42df8533e..43b1dc5f3 100644 --- a/website/content/docs/plugins/notifiers/slack.mdx +++ b/website/content/docs/plugins/notifiers/slack.mdx @@ -5,36 +5,70 @@ description: Slack incoming webhook. Minimal config, no bot token required.
- Slot: notifier · Name: slack + + Slot: notifier · Name: slack +
## Setup -1. In Slack, **Apps → Incoming Webhooks → Add to Slack** on the target workspace. -2. Pick the channel, copy the webhook URL. -3. Add it to AO: +Run the setup command: + +```bash +ao setup slack +``` + +If Slack is already configured, the command asks whether to keep the existing +webhook URL, change it, create a new one, or cancel. If you already have a +Slack incoming webhook URL, paste it when prompted. If not, the command shows +the Slack apps URL and waits while you create one. From the setup steps screen +you can paste the URL, show the steps again, go back to the previous options, or +cancel. The change-webhook path also lets you paste a new URL, view creation +steps, go back, or cancel. + +1. Open **https://api.slack.com/apps**. +2. Create a new app, or select an existing app. +3. Open **Incoming Webhooks** and turn on **Activate Incoming Webhooks**. +4. Select **Add New Webhook to Workspace**. +5. Pick the channel AO should post to, authorize, and copy the webhook URL. + +The command sends a test message before writing AO config. +Before saving, AO also asks which notification priorities Slack should receive: +`urgent-only`, `urgent-action`, or `all`. For scriptable setup, pass +`--routing-preset `. + +Useful setup checks: + +```bash +ao setup slack --status +ao setup slack --refresh +``` ```yaml title="agent-orchestrator.yaml" -notifier: - - type: slack - webhookUrl: ${SLACK_WEBHOOK_URL} - channel: "#agents" # optional — override default channel - username: "AO" # optional +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/... + username: "Agent Orchestrator" + channel: "#agents" # optional; should match the webhook's channel ``` ## Config -| Key | Required | Default | What it does | -|---|---|---|---| -| `webhookUrl` | ✓ | — | Slack incoming webhook URL | -| `channel` | optional | webhook default | Channel name (must be accessible to the webhook) | -| `username` | optional | `Agent Orchestrator` | Bot display name | +| Key | Required | Default | What it does | +| ------------ | -------- | -------------------- | --------------------------------------------------------------------------------------------------- | +| `webhookUrl` | ✓ | — | Slack incoming webhook URL | +| `channel` | optional | webhook default | Informational/compatibility channel name; should match the channel chosen when creating the webhook | +| `username` | optional | `Agent Orchestrator` | Requested display name | ## Notes - No bot token / OAuth to manage. Incoming webhooks are the simplest integration Slack offers. +- Slack incoming webhook URLs are tied to the channel selected during setup. Do not rely on `channel` to reroute messages to a different channel. +- For private channels, the installing Slack user must already be in the channel before creating the webhook. +- Treat the webhook URL as a secret; do not commit it publicly. - This notifier does **not** handle `Retry-After` headers — if you're sending at high volume, use the [webhook notifier](/docs/plugins/notifiers/webhook) with retries configured. - Messages use Slack Block Kit (header + section + context blocks). Action URLs render as inline links in the context block. diff --git a/website/content/docs/plugins/notifiers/webhook.mdx b/website/content/docs/plugins/notifiers/webhook.mdx index 1b47acf88..306876a9c 100644 --- a/website/content/docs/plugins/notifiers/webhook.mdx +++ b/website/content/docs/plugins/notifiers/webhook.mdx @@ -5,34 +5,56 @@ description: Generic HTTP POST. Retries, exponential backoff, custom headers.
- Slot: notifier · Name: webhook + + Slot: notifier · Name: webhook +
Point it at any HTTPS endpoint. AO POSTs a JSON event. Use this for PagerDuty-style integrations, custom dashboards, or anything we don't have a dedicated notifier for. +## Setup + +Run the setup command: + +```bash +ao setup webhook +``` + +If a webhook notifier is already configured, the command asks whether to use the +existing webhook URL, add a new webhook URL, or cancel. The add-new path lets you +paste a different endpoint, go back, or cancel before AO sends the setup test. +Before saving, AO also asks which notification priorities the webhook should +receive: `urgent-only`, `urgent-action`, or `all`. For scriptable setup, pass +`--routing-preset `. + ## Use ```yaml title="agent-orchestrator.yaml" -notifier: - - type: webhook +notifiers: + webhook: + plugin: webhook url: https://example.com/ao-events headers: Authorization: "Bearer ${MY_TOKEN}" X-Source: "agent-orchestrator" - retries: 5 # default 3 - retryDelayMs: 2000 # default 1000 + retries: 5 # default 3 + retryDelayMs: 2000 # default 1000 + +notificationRouting: + urgent: [webhook] + action: [webhook] ``` ## Config -| Key | Required | Default | What it does | -|---|---|---|---| -| `url` | ✓ | — | HTTPS endpoint that accepts POST | -| `headers` | optional | `{}` | Custom request headers (supports `${ENV_VAR}` interpolation) | -| `retries` | optional | `3` | Retry count on 429/5xx/network errors | -| `retryDelayMs` | optional | `1000` | Base retry delay (exponential backoff) | +| Key | Required | Default | What it does | +| -------------- | -------- | ------- | ------------------------------------------------------------ | +| `url` | ✓ | — | HTTPS endpoint that accepts POST | +| `headers` | optional | `{}` | Custom request headers (supports `${ENV_VAR}` interpolation) | +| `retries` | optional | `3` | Retry count on 429/5xx/network errors | +| `retryDelayMs` | optional | `1000` | Base retry delay (exponential backoff) | ## Payload @@ -62,9 +84,7 @@ When actions are attached (e.g. approve/retry buttons in supported notifiers): { "type": "notification_with_actions", "event": { "...": "same fields as above" }, - "actions": [ - { "label": "View PR", "url": "https://github.com/owner/repo/pull/123" } - ] + "actions": [{ "label": "View PR", "url": "https://github.com/owner/repo/pull/123" }] } ``` @@ -80,37 +100,37 @@ Free-form messages sent via `ao send` arrive as: ### Event types -| `event.type` | Default priority | When emitted | -|---|---|---| -| `session.spawned` | `info` | Session created | -| `session.working` | `info` | Agent begins working on the issue | -| `session.exited` | `info` | Agent process exited cleanly | -| `session.killed` | `info` | Session was manually killed | -| `session.idle` | `info` | Agent has been idle for a while | -| `session.stuck` | `urgent` | Agent appears stuck (no activity) | -| `session.needs_input` | `urgent` | Agent is blocked waiting for user input | -| `session.errored` | `urgent` | Agent hit an unrecoverable error | -| `pr.created` | `info` | PR opened by the agent | -| `pr.updated` | `info` | PR updated (new commits pushed) | -| `pr.merged` | `action` | PR was merged | -| `pr.closed` | `info` | PR was closed without merging | -| `ci.passing` | `info` | CI checks passed | -| `ci.failing` | `warning` | CI checks failed | -| `ci.fix_sent` | `info` | Agent sent a CI fix to itself | -| `ci.fix_failed` | `warning` | Agent's CI fix attempt failed | -| `review.pending` | `info` | PR review requested | -| `review.approved` | `action` | PR approved by reviewer | -| `review.changes_requested` | `warning` | Reviewer requested changes | -| `review.comments_sent` | `info` | Review comments sent to agent | -| `review.comments_unresolved` | `info` | Agent left unresolved review comments | -| `automated_review.found` | `warning` | Automated review tool (e.g. BugBot) left comments | -| `automated_review.fix_sent` | `info` | Agent sent fixes for automated review comments | -| `merge.ready` | `action` | PR is approved + CI green — ready to merge | -| `merge.conflicts` | `warning` | Merge conflicts detected | -| `merge.completed` | `action` | PR was successfully merged | -| `reaction.triggered` | `info` | A configured reaction was triggered | -| `reaction.escalated` | `urgent` | A reaction was escalated to a human notifier | -| `summary.all_complete` | `info` | All sessions in the project completed | +| `event.type` | Default priority | When emitted | +| ---------------------------- | ---------------- | ------------------------------------------------- | +| `session.spawned` | `info` | Session created | +| `session.working` | `info` | Agent begins working on the issue | +| `session.exited` | `info` | Agent process exited cleanly | +| `session.killed` | `info` | Session was manually killed | +| `session.idle` | `info` | Agent has been idle for a while | +| `session.stuck` | `urgent` | Agent appears stuck (no activity) | +| `session.needs_input` | `urgent` | Agent is blocked waiting for user input | +| `session.errored` | `urgent` | Agent hit an unrecoverable error | +| `pr.created` | `info` | PR opened by the agent | +| `pr.updated` | `info` | PR updated (new commits pushed) | +| `pr.merged` | `action` | PR was merged | +| `pr.closed` | `info` | PR was closed without merging | +| `ci.passing` | `info` | CI checks passed | +| `ci.failing` | `warning` | CI checks failed | +| `ci.fix_sent` | `info` | Agent sent a CI fix to itself | +| `ci.fix_failed` | `warning` | Agent's CI fix attempt failed | +| `review.pending` | `info` | PR review requested | +| `review.approved` | `action` | PR approved by reviewer | +| `review.changes_requested` | `warning` | Reviewer requested changes | +| `review.comments_sent` | `info` | Review comments sent to agent | +| `review.comments_unresolved` | `info` | Agent left unresolved review comments | +| `automated_review.found` | `warning` | Automated review tool (e.g. BugBot) left comments | +| `automated_review.fix_sent` | `info` | Agent sent fixes for automated review comments | +| `merge.ready` | `action` | PR is approved + CI green — ready to merge | +| `merge.conflicts` | `warning` | Merge conflicts detected | +| `merge.completed` | `action` | PR was successfully merged | +| `reaction.triggered` | `info` | A configured reaction was triggered | +| `reaction.escalated` | `urgent` | A reaction was escalated to a human notifier | +| `summary.all_complete` | `info` | All sessions in the project completed | ### Signature verification