Merge remote-tracking branch 'origin/main' into codex/implement-remote-control
# Conflicts: # packages/core/src/global-config.ts # packages/web/src/components/Dashboard.tsx # packages/web/src/components/SessionDetailHeader.tsx
This commit is contained in:
commit
0f1a549cb5
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-web": minor
|
||||
---
|
||||
|
||||
Wire activity events into webhook ingress and the mux WebSocket terminal server (sub-issue of #1511, follows #1620).
|
||||
|
||||
- `api.webhook_unverified` (warn) — signature verification failed; data includes `slug`, `remoteAddr`, `candidateCount` (never the failed signature)
|
||||
- `api.webhook_rejected` (warn) — payload exceeded `maxBodyBytes`; data includes counts and `maxBodyBytes` (never the body)
|
||||
- `api.webhook_received` (info|warn) — accepted webhook; data includes `projectIds`, `matchedSessions`, `parseErrorCount`, `lifecycleErrorCount` (never the body)
|
||||
- `api.webhook_failed` (error) — outer pipeline crash with `errorMessage`
|
||||
- `ui.terminal_connected` / `ui.terminal_disconnected` — one event per mux WS connection lifecycle
|
||||
- `ui.terminal_heartbeat_lost` (warn) — fires once on 3 missed pongs (was console-only)
|
||||
- `ui.terminal_pty_lost` (warn) — fires when PTY exits with subscribers attached (distinguishes "PTY died" from "user closed browser")
|
||||
- `ui.terminal_protocol_error` (warn) — invalid mux client message
|
||||
- `ui.session_broadcast_failed` (warn) — emitted on the healthy→failing transition only (re-arms after a successful poll), so a long outage produces one event, not 20/min
|
||||
|
||||
`api.webhook_unverified` is the security-audit event; treat 401s on webhooks as a signal worth retaining for the full 7-day window.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-plugin-agent-claude-code": patch
|
||||
---
|
||||
|
||||
Split Claude Code activity-detection logic out of `index.ts` into a dedicated `activity-detection.ts` module. Removes two unreachable switch branches (`case "permission_request"` → `waiting_input` and `case "error"` → `blocked`) that targeted JSONL types Claude never actually emits. `waiting_input` continues to flow through the AO activity-JSONL safety net added in #1903.
|
||||
|
||||
Closes the `blocked` gap for Claude Code: extend `readLastJsonlEntry` in core to also surface top-level `subtype` and `level` fields, and map `{type:"system", level:"error"}` → `blocked` in the cascade. This catches Claude's real api_error shape (`{type:"system", subtype:"api_error", level:"error", cause:{code:"ConnectionRefused"|"FailedToOpenSocket"|...}}`) so a session stuck in the API retry loop now reports `blocked` instead of `ready`. New fields on `readLastJsonlEntry` are additive and don't break existing callers (Codex, OpenCode, Aider).
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-agent-claude-code": patch
|
||||
---
|
||||
|
||||
Harden Claude Code activity detection against five real-world edge cases identified during PR #1927's analysis:
|
||||
|
||||
1. **Bookkeeping types → false-active.** `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title` were falling through to the `default` switch branch and showing as `active` for 30s after Claude finished a turn. They now correctly map to `ready`/`idle` by age. Likely root cause of "Claude looks busy when it's done" reports.
|
||||
|
||||
2. **Multi-session disambiguation.** `findLatestSessionFile` picked newest-mtime, which is the wrong session's JSONL when two Claude sessions are running in the same workspace. Now prefers the UUID-named file (`<projectDir>/<claudeSessionUuid>.jsonl`) when `session.metadata.claudeSessionUuid` is set, falling back to newest-mtime otherwise.
|
||||
|
||||
3. **Symlinked workspaces.** `toClaudeProjectPath` was a pure string transform — symlink paths produced different slugs than what Claude itself wrote. Added `resolveWorkspaceForClaude(path)` that runs `realpathSync` (with fallback) and used it in all three slug-computing sites (`getClaudeActivityState`, `getSessionInfo`, `getRestoreCommand`).
|
||||
|
||||
4. **Process regex too narrow.** `(?:^|\/)claude(?:\s|$)` was missing several legitimate install variants — `.claude`, `claude-code`, `claude.exe`, `claude.js`, and npm shims like `node /opt/.../@anthropic-ai/claude-code/cli.js`. Broadened to `(?:^|\/)(?:\.)?claude(?:[-.][\w-]+)*(?:[\s/]|$)`; still rejects look-alikes (`claudia`, `claudine`).
|
||||
|
||||
5. **Silent permission-denied.** `findLatestSessionFile` was swallowing every `readdir` error silently — a missing `~/.claude/projects/<slug>/` (ENOENT) is normal, but a permission-denied (EACCES/EPERM) or fd-exhausted (EMFILE) misconfig left the session looking permanently `idle` on the dashboard with zero telemetry. Now logs a single `console.warn` for non-ENOENT errors.
|
||||
|
||||
193/193 plugin tests pass. No public-API change. New helper `resolveWorkspaceForClaude` is re-exported from `index.ts` for downstream consumers.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
---
|
||||
|
||||
Wire CLI activity events into `ao start`, `ao stop`, `ao spawn`, `ao update`, `ao setup`, `ao migrate-storage`, and shared CLI helpers. `ao events list --source cli` now answers RCA questions like "did AO start cleanly?", "was AO killed or did it crash?", and "did `ao spawn`/`ao stop` fail and why?". Adds `"cli"` to the `ActivityEventSource` union and 30+ event-emit sites covering startup, graceful and forced shutdown, restore, project resolution, config recovery, and migration paths.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Fix Done/Terminated section on the dashboard not scrolling. The dashboard body now scrolls as a single page — kanban above, Done/Terminated below — so older terminated sessions are reachable when many accumulate. Restores the pre-Warm-Terminal-refresh behavior by dropping the `flex: 1` that was making `.kanban-board-wrap` greedily consume all remaining body height and defeat the body's `overflow-y: auto`.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-cli": patch
|
||||
"@aoagents/ao-core": minor
|
||||
---
|
||||
|
||||
Wire activity events for the recovery subsystem, metadata-corruption detection, and agent-report apply path. New event kinds: `recovery.session_failed`, `recovery.action_failed`, `metadata.corrupt_detected`, `api.agent_report.session_not_found`, `api.agent_report.transition_rejected`. Adds `"recovery"` to the `ActivityEventSource` union. Lets RCA reconstruct `ao recover` invocations, find every silent metadata overwrite, and audit rejected agent transitions. Adds `ao events list --source` and `--kind` so these forensic event queries are available from the CLI.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-web": minor
|
||||
---
|
||||
|
||||
feat: "Launch Orchestrator (clean context)" action on the orchestrator session page
|
||||
|
||||
Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-plugin-tracker-linear": patch
|
||||
---
|
||||
|
||||
Retry transient Linear API HTTP failures in the direct transport to reduce flakes from brief 5xx/429 responses.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
"@aoagents/ao-cli": patch
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-web": patch
|
||||
"@aoagents/ao-notifier-macos": patch
|
||||
"@aoagents/ao-plugin-notifier-composio": patch
|
||||
"@aoagents/ao-plugin-notifier-dashboard": patch
|
||||
"@aoagents/ao-plugin-notifier-desktop": patch
|
||||
"@aoagents/ao-plugin-notifier-discord": patch
|
||||
"@aoagents/ao-plugin-notifier-openclaw": patch
|
||||
"@aoagents/ao-plugin-notifier-slack": patch
|
||||
---
|
||||
|
||||
Add the notifier test harness, dashboard notifications, and desktop notifier setup.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@aoagents/ao": patch
|
||||
"@aoagents/ao-core": patch
|
||||
"@aoagents/ao-cli": patch
|
||||
---
|
||||
|
||||
Rebuild missing better-sqlite3 native bindings during ao postinstall and replace noisy activity-events native-binding failures with a one-line diagnostic.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
fix(web): show Restore button for every exited session, including pr_merged
|
||||
|
||||
The Restore button was hidden for sessions exited with `pr_merged` reason (legacy
|
||||
status `cleanup`) on the dashboard kanban and absent altogether from the
|
||||
session-detail "Terminal ended" panel. The core `isRestorable()` helper already
|
||||
allowed restoring these sessions; the dashboard helpers were out of sync. Fixes
|
||||
#1907.
|
||||
|
||||
- `isDashboardSessionRestorable` now gates on `NON_RESTORABLE_STATUSES` only,
|
||||
matching core's `isRestorable`. Merged-but-running sessions remain
|
||||
non-restorable (lifecycle isn't terminal).
|
||||
- `DoneCard` no longer hides Restore for merged sessions.
|
||||
- `SessionEndedSummary` exposes a prominent `Restore session` button next to
|
||||
`Open PR` / `Back to dashboard`, so users don't have to find the small
|
||||
header icon.
|
||||
|
|
@ -111,7 +111,14 @@ jobs:
|
|||
# create a minimal one. `changeset version --snapshot` consumes and
|
||||
# deletes it, so no cleanup is needed.
|
||||
if [ -z "$(ls .changeset/*.md 2>/dev/null | grep -vi 'README')" ]; then
|
||||
printf -- '---\n"@aoagents/ao": patch\n---\n\nchore: canary build\n' > .changeset/canary-temp.md
|
||||
node << 'SCRIPT'
|
||||
const cfg = require('./.changeset/config.json');
|
||||
const pkgs = cfg.linked.flat();
|
||||
let body = '---\n';
|
||||
pkgs.forEach(p => body += '"' + p + '": patch\n');
|
||||
body += '---\n\nchore: canary build\n';
|
||||
require('fs').writeFileSync('.changeset/canary-temp.md', body);
|
||||
SCRIPT
|
||||
fi
|
||||
pnpm changeset version --snapshot nightly
|
||||
env:
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -111,7 +111,7 @@ spawning -> working -> pr_open -> ci_failed / review_pending
|
|||
+-> mergeable -> merged -> cleanup -> done
|
||||
```
|
||||
|
||||
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `runtime_lost` reason to disk. This maps to legacy status `killed`. Without this, sessions with dead runtimes would show stale "active" status indefinitely.
|
||||
**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735).
|
||||
|
||||
### Data Flow
|
||||
|
||||
|
|
@ -224,7 +224,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
|||
- Kanban board filters client-side via `projectSessions` memo
|
||||
|
||||
### Key invariants
|
||||
- `sm.list()` persists `runtime_lost` lifecycle to disk when enrichment detects dead runtimes — this is the only place stale runtime state gets reconciled
|
||||
- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735)
|
||||
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
|
||||
- Tab completions merge local config + global config to show all projects
|
||||
|
||||
|
|
@ -450,8 +450,8 @@ import {
|
|||
validateUrl, // Webhook URL validation
|
||||
readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL)
|
||||
readLastActivityEntry, // Read last AO activity JSONL entry
|
||||
checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap)
|
||||
getActivityFallbackState, // Last-resort fallback: entry state + age-based decay
|
||||
checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL
|
||||
getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay
|
||||
recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append)
|
||||
classifyTerminalActivity, // Classify terminal output via detectActivity
|
||||
appendActivityEntry, // Low-level JSONL append
|
||||
|
|
@ -460,7 +460,7 @@ import {
|
|||
normalizeAgentPermissionMode, // Normalize permission mode strings
|
||||
DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold
|
||||
DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window
|
||||
ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry
|
||||
ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock
|
||||
PREFERRED_GH_PATH, // /usr/local/bin/gh
|
||||
CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants
|
||||
type Session, type ProjectConfig, type RuntimeHandle,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ ao stop # Stop everything (dashboard, orchestrato
|
|||
ao status # Overview of all sessions
|
||||
ao status --watch # Live-updating terminal status view
|
||||
ao dashboard # Open web dashboard in browser
|
||||
ao setup dashboard # Configure dashboard notification retention/routing
|
||||
ao setup desktop # Install/configure native macOS desktop notifications
|
||||
ao notify test --to desktop # Send a manual notifier test without starting AO
|
||||
ao completion zsh # Print the zsh completion script
|
||||
```
|
||||
|
||||
|
|
@ -42,6 +45,7 @@ ao session restore <session> # Revive a crashed agent
|
|||
```bash
|
||||
ao doctor # Check install, runtime, and stale temp issues
|
||||
ao doctor --fix # Apply safe fixes automatically
|
||||
ao setup openclaw # Connect AO notifications to OpenClaw
|
||||
ao update # Update local AO install (source installs only)
|
||||
ao config-help # Show full config schema reference
|
||||
```
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ This document describes runtime observability emitted by Agent Orchestrator.
|
|||
|
||||
## Emission Model
|
||||
|
||||
- **Structured logs**: JSON lines on stderr, controlled by `AO_LOG_LEVEL`.
|
||||
- **Structured logs**: JSON lines on stderr when enabled by config or `AO_OBSERVABILITY_STDERR`.
|
||||
- Supported levels: `debug`, `info`, `warn`, `error`.
|
||||
- Default level: `warn` (production-safe, avoids high-volume info logs).
|
||||
- Default stderr mirroring: disabled.
|
||||
- Runtime env vars override YAML:
|
||||
- `AO_LOG_LEVEL=info`
|
||||
- `AO_OBSERVABILITY_STDERR=1`
|
||||
- **Durable snapshots**: process-local JSON snapshots under:
|
||||
- `~/.agent-orchestrator/{config-hash}-observability/processes/*.json`
|
||||
- **Aggregated view**: merged by project via:
|
||||
|
|
@ -38,6 +42,7 @@ Counters are emitted per project and operation:
|
|||
- `lifecycle_poll` (`lifecycle.merge_cleanup.completed`) — auto-cleanup ran after a PR was detected as merged; session runtime + worktree + metadata were torn down
|
||||
- `lifecycle_poll` (`lifecycle.merge_cleanup.deferred`) — auto-cleanup is waiting for the agent to idle (or for the `mergeCleanupIdleGraceMs` window to elapse) before tearing down
|
||||
- `lifecycle_poll` (`lifecycle.merge_cleanup.failed`) — auto-cleanup threw during `sessionManager.kill()`; the session stays in `merged` so the next poll retries
|
||||
- `notification_delivery` (`notification.deliver`) — per-target notifier dispatch success/failure, with event ID/type, priority, project/session IDs, target reference, plugin name, and delivery method
|
||||
- `api_request` (web API routes)
|
||||
- `sse_connect`, `sse_snapshot`, `sse_disconnect`
|
||||
- `websocket_connect`, `websocket_disconnect`, `websocket_error` (websocket servers)
|
||||
|
|
@ -83,10 +88,11 @@ Health records provide current status and failure context per surface:
|
|||
- **Dashboard**: use **Copy debug info** in the hero toolbar (desktop) to copy `/api/observability` plus page URL, project scope, and correlation id to the clipboard for issue reports. The observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason.
|
||||
- **API**: `/api/observability` returns merged per-project diagnostics (`overallStatus`, metrics, health, recent traces, session state).
|
||||
- **Terminal websocket health**: `/health` endpoints include active sessions and websocket/terminal health counters with last error/disconnect reasons.
|
||||
- **Notifications**: successful deliveries update `notification_delivery` metrics and per-target health; failed/missing targets also appear in `ao events`.
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
1. Deploy with default `AO_LOG_LEVEL=warn` to avoid noisy logs.
|
||||
1. Deploy with default `observability.logLevel: warn` and `observability.stderr: false` to avoid noisy logs.
|
||||
2. Validate `/api/observability` and dashboard banner in a canary environment.
|
||||
3. If deeper triage is needed, temporarily raise `AO_LOG_LEVEL=info` (or `debug`), then revert to `warn`.
|
||||
3. If deeper triage is needed, temporarily raise `AO_LOG_LEVEL=info` (or `debug`) and set `AO_OBSERVABILITY_STDERR=1`, then revert to defaults.
|
||||
4. Monitor `lastFailureReason` and surface-level `reason` fields before enabling broader rollout.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 27eeea0555a0a15b19cea615f46d1f8b88b9dbc1
|
||||
|
|
@ -11,19 +11,28 @@
|
|||
* If not (common with nvm/fnm/volta), rebuilds from source via npx node-gyp.
|
||||
* See: https://github.com/ComposioHQ/agent-orchestrator/issues/987
|
||||
*
|
||||
* 3. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web
|
||||
* 3. Verifies better-sqlite3 has a native binding for this Node ABI.
|
||||
* Node majors can ship new NODE_MODULE_VERSION values before better-sqlite3
|
||||
* publishes matching prebuilds; global installs must rebuild from source.
|
||||
* See: https://github.com/ComposioHQ/agent-orchestrator/issues/1822
|
||||
*
|
||||
* 4. Clears stale Next.js runtime cache (.next/cache) from @composio/ao-web
|
||||
* after a version upgrade, so `ao start` serves fresh dashboard assets.
|
||||
* Writes a version stamp (.next/AO_VERSION) to skip cleanup on subsequent runs.
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function findPackageUp(startDir, ...segments) {
|
||||
function isWindows() {
|
||||
return process.platform === "win32";
|
||||
}
|
||||
|
||||
export function findPackageUp(startDir, ...segments) {
|
||||
let dir = resolve(startDir);
|
||||
while (true) {
|
||||
const candidate = resolve(dir, "node_modules", ...segments);
|
||||
|
|
@ -35,12 +44,12 @@ function findPackageUp(startDir, ...segments) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function resolveNodeModulesPackage(fromDir, ...segments) {
|
||||
export function resolveNodeModulesPackage(fromDir, ...segments) {
|
||||
const packageDir = resolve(fromDir, "node_modules", ...segments);
|
||||
return existsSync(resolve(packageDir, "package.json")) ? packageDir : null;
|
||||
}
|
||||
|
||||
function findWebDir() {
|
||||
export function findWebDir() {
|
||||
const directWebDir = findPackageUp(__dirname, "@aoagents", "ao-web");
|
||||
if (directWebDir) return directWebDir;
|
||||
|
||||
|
|
@ -50,8 +59,112 @@ function findWebDir() {
|
|||
return resolveNodeModulesPackage(cliDir, "@aoagents", "ao-web");
|
||||
}
|
||||
|
||||
// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) ---
|
||||
if (process.platform !== "win32") {
|
||||
export function findBetterSqlite3Dir() {
|
||||
const directBetterSqlite3Dir = findPackageUp(__dirname, "better-sqlite3");
|
||||
if (directBetterSqlite3Dir) return directBetterSqlite3Dir;
|
||||
|
||||
const cliDir = findPackageUp(__dirname, "@aoagents", "ao-cli");
|
||||
if (!cliDir) return null;
|
||||
|
||||
const coreDir = resolveNodeModulesPackage(cliDir, "@aoagents", "ao-core");
|
||||
if (!coreDir) return null;
|
||||
|
||||
return (
|
||||
resolveNodeModulesPackage(coreDir, "better-sqlite3") ?? findPackageUp(coreDir, "better-sqlite3")
|
||||
);
|
||||
}
|
||||
|
||||
export function betterSqlite3BindingCandidates(
|
||||
packageDir,
|
||||
{
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
modules = process.versions.modules,
|
||||
nodeVersion = process.versions.node,
|
||||
} = {},
|
||||
) {
|
||||
return [
|
||||
resolve(packageDir, "build", "better_sqlite3.node"),
|
||||
resolve(packageDir, "build", "Debug", "better_sqlite3.node"),
|
||||
resolve(packageDir, "build", "Release", "better_sqlite3.node"),
|
||||
resolve(packageDir, "out", "Debug", "better_sqlite3.node"),
|
||||
resolve(packageDir, "Debug", "better_sqlite3.node"),
|
||||
resolve(packageDir, "out", "Release", "better_sqlite3.node"),
|
||||
resolve(packageDir, "Release", "better_sqlite3.node"),
|
||||
resolve(packageDir, "build", "default", "better_sqlite3.node"),
|
||||
resolve(packageDir, "compiled", nodeVersion, platform, arch, "better_sqlite3.node"),
|
||||
resolve(packageDir, "addon-build", "release", "install-root", "better_sqlite3.node"),
|
||||
resolve(packageDir, "addon-build", "debug", "install-root", "better_sqlite3.node"),
|
||||
resolve(packageDir, "addon-build", "default", "install-root", "better_sqlite3.node"),
|
||||
resolve(
|
||||
packageDir,
|
||||
"lib",
|
||||
"binding",
|
||||
`node-v${modules}-${platform}-${arch}`,
|
||||
"better_sqlite3.node",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function hasBetterSqlite3Binding(packageDir, options = {}) {
|
||||
const fileExists = options.existsSync ?? existsSync;
|
||||
return betterSqlite3BindingCandidates(packageDir, options).some((candidate) =>
|
||||
fileExists(candidate),
|
||||
);
|
||||
}
|
||||
|
||||
export function betterSqlite3RebuildCommand(packageDir, env = process.env) {
|
||||
const packageManager =
|
||||
`${env.npm_config_user_agent ?? ""} ${env.npm_execpath ?? ""}`.toLowerCase();
|
||||
if (packageManager.includes("npm") && !packageManager.includes("pnpm")) {
|
||||
return { command: "npm", args: ["rebuild"], display: `cd ${packageDir} && npm rebuild` };
|
||||
}
|
||||
return {
|
||||
command: "pnpm",
|
||||
args: ["--dir", packageDir, "rebuild"],
|
||||
display: `pnpm --dir ${packageDir} rebuild`,
|
||||
};
|
||||
}
|
||||
|
||||
function checkBetterSqlite3Binding() {
|
||||
const betterSqlite3Dir = findBetterSqlite3Dir();
|
||||
if (!betterSqlite3Dir) {
|
||||
console.warn(
|
||||
"⚠️ better-sqlite3 package not found; skipping activity-events native binding check",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const abi = process.versions.modules;
|
||||
if (hasBetterSqlite3Binding(betterSqlite3Dir)) {
|
||||
console.log(
|
||||
`✓ better-sqlite3 native binding present for Node ${process.version} (ABI v${abi})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { command, args, display } = betterSqlite3RebuildCommand(betterSqlite3Dir);
|
||||
try {
|
||||
execFileSync(command, args, {
|
||||
cwd: betterSqlite3Dir,
|
||||
stdio: "ignore",
|
||||
timeout: 120000,
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
});
|
||||
console.log(
|
||||
`✓ better-sqlite3 native binding rebuilt for Node ${process.version} (ABI v${abi})`,
|
||||
);
|
||||
} catch {
|
||||
console.warn(
|
||||
`⚠️ better-sqlite3 rebuild failed for Node ${process.version} (ABI v${abi}) — activity events may be unavailable. Manual fix: ${display}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fixNodePty() {
|
||||
if (isWindows()) return;
|
||||
|
||||
const nodePtyDir = findPackageUp(__dirname, "node-pty");
|
||||
if (nodePtyDir) {
|
||||
const spawnHelper = resolve(
|
||||
|
|
@ -84,7 +197,11 @@ if (process.platform !== "win32") {
|
|||
},
|
||||
);
|
||||
} catch {
|
||||
console.log("⚠️ node-pty prebuilt binary incompatible with Node.js " + process.version + ", rebuilding...");
|
||||
console.log(
|
||||
"⚠️ node-pty prebuilt binary incompatible with Node.js " +
|
||||
process.version +
|
||||
", rebuilding...",
|
||||
);
|
||||
try {
|
||||
execSync("npx --yes node-gyp rebuild", {
|
||||
cwd: nodePtyDir,
|
||||
|
|
@ -100,27 +217,43 @@ if (process.platform !== "win32") {
|
|||
}
|
||||
}
|
||||
|
||||
// --- 3. Clear stale Next.js runtime cache after version upgrade ---
|
||||
try {
|
||||
const webDir = findWebDir();
|
||||
if (webDir) {
|
||||
const pkgPath = resolve(webDir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const version = pkg.version;
|
||||
const cacheDir = resolve(webDir, ".next", "cache");
|
||||
const stampPath = resolve(webDir, ".next", "AO_VERSION");
|
||||
function clearDashboardCache() {
|
||||
try {
|
||||
const webDir = findWebDir();
|
||||
if (webDir) {
|
||||
const pkgPath = resolve(webDir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const version = pkg.version;
|
||||
const cacheDir = resolve(webDir, ".next", "cache");
|
||||
const stampPath = resolve(webDir, ".next", "AO_VERSION");
|
||||
|
||||
if (existsSync(cacheDir)) {
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
console.log("✓ Cleared stale .next/cache");
|
||||
}
|
||||
if (existsSync(resolve(webDir, ".next"))) {
|
||||
writeFileSync(stampPath, version, "utf8");
|
||||
console.log(`✓ Dashboard version stamp set to ${version}`);
|
||||
if (existsSync(cacheDir)) {
|
||||
rmSync(cacheDir, { recursive: true, force: true });
|
||||
console.log("✓ Cleared stale .next/cache");
|
||||
}
|
||||
if (existsSync(resolve(webDir, ".next"))) {
|
||||
writeFileSync(stampPath, version, "utf8");
|
||||
console.log(`✓ Dashboard version stamp set to ${version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Could not clear dashboard cache (non-critical): ${err.message}`);
|
||||
}
|
||||
|
||||
export function runPostinstall() {
|
||||
// --- 1 & 2. Fix node-pty spawn-helper permissions and verify ABI (non-Windows only) ---
|
||||
fixNodePty();
|
||||
|
||||
// --- 3. Ensure better-sqlite3 has a native binding for this Node ABI ---
|
||||
checkBetterSqlite3Binding();
|
||||
|
||||
// --- 4. Clear stale Next.js runtime cache after version upgrade ---
|
||||
clearDashboardCache();
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
runPostinstall();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const {
|
|||
mockFindConfigFile,
|
||||
mockLoadConfig,
|
||||
mockCreatePluginRegistry,
|
||||
mockCreateProjectObserver,
|
||||
mockRecordNotificationDelivery,
|
||||
mockDetectOpenClawInstallation,
|
||||
mockValidateToken,
|
||||
mockRegistry,
|
||||
|
|
@ -18,6 +20,8 @@ const {
|
|||
mockFindConfigFile: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockCreatePluginRegistry: vi.fn(),
|
||||
mockCreateProjectObserver: vi.fn(),
|
||||
mockRecordNotificationDelivery: vi.fn(),
|
||||
mockDetectOpenClawInstallation: vi.fn(),
|
||||
mockValidateToken: vi.fn(),
|
||||
mockRegistry: {
|
||||
|
|
@ -36,10 +40,16 @@ vi.mock("../../src/lib/script-runner.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
buildCIFailureNotificationData: () => ({ schemaVersion: 3 }),
|
||||
buildPRStateNotificationData: () => ({ schemaVersion: 3 }),
|
||||
buildReactionNotificationData: () => ({ schemaVersion: 3 }),
|
||||
buildSessionTransitionNotificationData: () => ({ schemaVersion: 3 }),
|
||||
createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args),
|
||||
createProjectObserver: (...args: unknown[]) => mockCreateProjectObserver(...args),
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability",
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args),
|
||||
resolveNotifierTarget: (
|
||||
config: { notifiers?: Record<string, { plugin?: string }> },
|
||||
reference: string,
|
||||
|
|
@ -157,6 +167,12 @@ describe("doctor command", () => {
|
|||
|
||||
mockCreatePluginRegistry.mockReset();
|
||||
mockCreatePluginRegistry.mockReturnValue(mockRegistry);
|
||||
mockCreateProjectObserver.mockReset();
|
||||
mockCreateProjectObserver.mockReturnValue({
|
||||
recordOperation: vi.fn(),
|
||||
setHealth: vi.fn(),
|
||||
});
|
||||
mockRecordNotificationDelivery.mockReset();
|
||||
|
||||
mockRegistry.loadFromConfig.mockReset();
|
||||
mockRegistry.loadFromConfig.mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const { mockQueryActivityEvents, mockSearchActivityEvents, mockGetActivityEventStats } = vi.hoisted(
|
||||
() => ({
|
||||
mockQueryActivityEvents: vi.fn(),
|
||||
mockSearchActivityEvents: vi.fn(),
|
||||
mockGetActivityEventStats: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
queryActivityEvents: (...args: unknown[]) => mockQueryActivityEvents(...args),
|
||||
searchActivityEvents: (...args: unknown[]) => mockSearchActivityEvents(...args),
|
||||
getActivityEventStats: (...args: unknown[]) => mockGetActivityEventStats(...args),
|
||||
droppedEventCount: () => 0,
|
||||
isActivityEventsFtsEnabled: () => true,
|
||||
}));
|
||||
|
||||
import { registerEvents } from "../../src/commands/events.js";
|
||||
|
||||
describe("events command", () => {
|
||||
let program: Command;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerEvents(program);
|
||||
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
mockQueryActivityEvents.mockReset();
|
||||
mockSearchActivityEvents.mockReset();
|
||||
mockGetActivityEventStats.mockReset();
|
||||
mockQueryActivityEvents.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("filters list output by source and --kind alias", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"events",
|
||||
"list",
|
||||
"--source",
|
||||
"recovery",
|
||||
"--kind",
|
||||
"metadata.corrupt_detected",
|
||||
"--limit",
|
||||
"1",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(mockQueryActivityEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "recovery",
|
||||
kind: "metadata.corrupt_detected",
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('"source": "recovery"'));
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"kind": "metadata.corrupt_detected"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps --type as the existing event-kind filter", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"events",
|
||||
"list",
|
||||
"--type",
|
||||
"recovery.session_failed",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(mockQueryActivityEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "recovery.session_failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Tests for migrate-storage activity-event instrumentation (issue #1654).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockMigrateStorage, mockRollbackStorage } = vi.hoisted(() => ({
|
||||
mockMigrateStorage: vi.fn(),
|
||||
mockRollbackStorage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
migrateStorage: (...args: unknown[]) => mockMigrateStorage(...args),
|
||||
rollbackStorage: (...args: unknown[]) => mockRollbackStorage(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerMigrateStorage } from "../../src/commands/migrate-storage.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
describe("ao migrate-storage — activity events", () => {
|
||||
let program: Command;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(AoCore.recordActivityEvent).mockClear();
|
||||
mockMigrateStorage.mockReset();
|
||||
mockRollbackStorage.mockReset();
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerMigrateStorage(program);
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
|
||||
consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
consoleErrSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("emits cli.migration_invoked before migration work starts", async () => {
|
||||
mockMigrateStorage.mockImplementation(async () => {
|
||||
expect(recordedEvents()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_invoked",
|
||||
source: "cli",
|
||||
level: "info",
|
||||
data: expect.objectContaining({
|
||||
rollback: false,
|
||||
dryRun: true,
|
||||
force: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return { projects: 1 };
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage", "--dry-run", "--force"]);
|
||||
|
||||
expect(mockMigrateStorage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("emits cli.migration_failed when migrateStorage throws", async () => {
|
||||
mockMigrateStorage.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
rollback: false,
|
||||
errorMessage: "disk full",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.migration_failed when rollbackStorage throws", async () => {
|
||||
mockRollbackStorage.mockRejectedValue(new Error("rollback boom"));
|
||||
|
||||
await program.parseAsync(["node", "ao", "migrate-storage", "--rollback"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.migration_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
rollback: true,
|
||||
errorMessage: "rollback boom",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
const {
|
||||
mockCreatePluginRegistry,
|
||||
mockCreateProjectObserver,
|
||||
mockFindConfigFile,
|
||||
mockLoadConfig,
|
||||
mockRecordNotificationDelivery,
|
||||
mockRegistry,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreatePluginRegistry: vi.fn(),
|
||||
mockCreateProjectObserver: vi.fn(),
|
||||
mockFindConfigFile: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockRecordNotificationDelivery: vi.fn(),
|
||||
mockRegistry: {
|
||||
loadFromConfig: vi.fn(),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
register: vi.fn(),
|
||||
loadBuiltins: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => {
|
||||
function buildSubject(input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
}) {
|
||||
return {
|
||||
session: { id: input.sessionId, projectId: input.projectId },
|
||||
...(input.context?.pr ? { pr: input.context.pr } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function baseData(input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
semanticType?: string;
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
semanticType: input.semanticType,
|
||||
subject: buildSubject(input),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args),
|
||||
createProjectObserver: (...args: unknown[]) => mockCreateProjectObserver(...args),
|
||||
findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args),
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args),
|
||||
resolveNotifierTarget: (
|
||||
config: { notifiers?: Record<string, { plugin?: string }> },
|
||||
reference: string,
|
||||
) => ({
|
||||
reference,
|
||||
pluginName: config.notifiers?.[reference]?.plugin ?? reference,
|
||||
}),
|
||||
buildCIFailureNotificationData: (input: {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
failedChecks: Array<Record<string, unknown>>;
|
||||
}) => ({
|
||||
...baseData({ ...input, semanticType: "ci.failing" }),
|
||||
ci: { status: "failing", failedChecks: input.failedChecks },
|
||||
}),
|
||||
buildPRStateNotificationData: (input: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
oldPRState: string;
|
||||
newPRState: string;
|
||||
}) => ({
|
||||
...baseData({ ...input, semanticType: input.eventType }),
|
||||
transition: { kind: "pr_state", from: input.oldPRState, to: input.newPRState },
|
||||
}),
|
||||
buildReactionNotificationData: (input: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
reactionKey: string;
|
||||
action: string;
|
||||
}) => ({
|
||||
...baseData({
|
||||
...input,
|
||||
semanticType:
|
||||
input.reactionKey === "all-complete" ? "summary.all_complete" : input.eventType,
|
||||
}),
|
||||
reaction: { key: input.reactionKey, action: input.action },
|
||||
}),
|
||||
buildSessionTransitionNotificationData: (input: {
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
context?: { pr?: Record<string, unknown> | null };
|
||||
oldStatus: string;
|
||||
newStatus: string;
|
||||
}) => ({
|
||||
...baseData({ ...input, semanticType: input.eventType }),
|
||||
transition: { kind: "session_status", from: input.oldStatus, to: input.newStatus },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/plugin-store.js", () => ({
|
||||
importPluginModuleFromSource: vi.fn(),
|
||||
}));
|
||||
|
||||
import { registerNotify } from "../../src/commands/notify.js";
|
||||
|
||||
function makeConfig() {
|
||||
return {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts"],
|
||||
},
|
||||
projects: {
|
||||
demo: {
|
||||
name: "Demo",
|
||||
path: "/tmp/demo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "demo",
|
||||
},
|
||||
},
|
||||
notifiers: {
|
||||
alerts: { plugin: "slack" },
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["alerts"],
|
||||
warning: ["alerts"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerNotify(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("notify command", () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
|
||||
mockFindConfigFile.mockReset();
|
||||
mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml");
|
||||
mockLoadConfig.mockReset();
|
||||
mockLoadConfig.mockReturnValue(makeConfig());
|
||||
mockCreatePluginRegistry.mockReset();
|
||||
mockCreatePluginRegistry.mockReturnValue(mockRegistry);
|
||||
mockCreateProjectObserver.mockReset();
|
||||
mockCreateProjectObserver.mockReturnValue({
|
||||
recordOperation: vi.fn(),
|
||||
setHealth: vi.fn(),
|
||||
});
|
||||
mockRecordNotificationDelivery.mockReset();
|
||||
mockRegistry.loadFromConfig.mockReset();
|
||||
mockRegistry.loadFromConfig.mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockReset();
|
||||
mockRegistry.list.mockReset();
|
||||
mockRegistry.register.mockReset();
|
||||
mockRegistry.loadBuiltins.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves a dry run without sending", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockReturnValue({ name: "alerts", notify });
|
||||
|
||||
await createProgram().parseAsync(["node", "test", "notify", "test", "--dry-run"]);
|
||||
|
||||
expect(mockRegistry.loadFromConfig).toHaveBeenCalledWith(makeConfig(), expect.any(Function));
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("Dry run");
|
||||
});
|
||||
|
||||
it("sends template data and valid --data overrides", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockReturnValue({ name: "alerts", notify });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"notify",
|
||||
"test",
|
||||
"--template",
|
||||
"ci-failing",
|
||||
"--data",
|
||||
'{"runId":"123"}',
|
||||
]);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify.mock.calls[0][0]).toMatchObject({
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
data: {
|
||||
schemaVersion: 3,
|
||||
subject: {
|
||||
pr: {
|
||||
number: 1579,
|
||||
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
status: "failing",
|
||||
failedChecks: [
|
||||
{ name: "typecheck", status: "failed" },
|
||||
{ name: "unit-tests", status: "failed" },
|
||||
],
|
||||
},
|
||||
runId: "123",
|
||||
},
|
||||
});
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits 1 for invalid --data JSON", async () => {
|
||||
await expect(
|
||||
createProgram().parseAsync(["node", "test", "notify", "test", "--data", "{bad"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(consoleErrorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain(
|
||||
"Invalid --data JSON",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures one sink webhook payload and closes cleanly", async () => {
|
||||
let sinkUrl = "";
|
||||
|
||||
mockRegistry.loadFromConfig.mockImplementation(
|
||||
(config: { notifiers: Record<string, { url?: string }> }) => {
|
||||
sinkUrl = config.notifiers.sink?.url ?? "";
|
||||
},
|
||||
);
|
||||
mockRegistry.get.mockImplementation((slot: string, name: string) => {
|
||||
if (slot !== "notifier" || name !== "sink") return null;
|
||||
return {
|
||||
name: "sink",
|
||||
notify: async (event: unknown) => {
|
||||
await fetch(sinkUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "notification", event }),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(["node", "test", "notify", "test", "--sink"]);
|
||||
|
||||
const output = consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
expect(output).toContain("Sink received");
|
||||
expect(output).toContain("Test notification from ao notify test");
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
|
||||
await expect(fetch(sinkUrl, { method: "POST", body: "{}" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not start a sink delivery in dry-run mode", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistry.get.mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "notifier" && name === "sink") {
|
||||
return { name: "sink", notify };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(["node", "test", "notify", "test", "--sink", "--dry-run"]);
|
||||
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n")).not.toContain(
|
||||
"Sink received",
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ vi.mock("@aoagents/ao-core", () => ({
|
|||
isPortfolioEnabled: () => true,
|
||||
getPortfolio: mockGetPortfolio,
|
||||
getPortfolioSessionCounts: mockGetPortfolioSessionCounts,
|
||||
recordActivityEvent: vi.fn(),
|
||||
registerProject: mockRegisterProject,
|
||||
unregisterProject: mockUnregisterProject,
|
||||
loadPreferences: mockLoadPreferences,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createActivitySignal,
|
||||
createInitialCanonicalLifecycle,
|
||||
createCodeReviewStore,
|
||||
type OrchestratorConfig,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
} from "@aoagents/ao-core";
|
||||
import type * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockConfigRef, mockSessionManager, reviewStoreRootRef } = vi.hoisted(() => ({
|
||||
mockConfigRef: { current: null as OrchestratorConfig | null },
|
||||
mockSessionManager: {
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
ensureOrchestrator: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
reviewStoreRootRef: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
const createIsolatedStore = (projectId: string, options: AoCore.CodeReviewStoreOptions = {}) =>
|
||||
actual.createCodeReviewStore(projectId, {
|
||||
...options,
|
||||
storeDir: options.storeDir ?? `${reviewStoreRootRef.current}/${projectId}`,
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createCodeReviewStore: createIsolatedStore,
|
||||
triggerCodeReviewForSession: (
|
||||
options: AoCore.TriggerCodeReviewOptions,
|
||||
input: AoCore.TriggerCodeReviewInput,
|
||||
) =>
|
||||
actual.triggerCodeReviewForSession(
|
||||
{
|
||||
...options,
|
||||
storeFactory: options.storeFactory ?? createIsolatedStore,
|
||||
},
|
||||
input,
|
||||
),
|
||||
executeCodeReviewRun: (
|
||||
options: AoCore.ExecuteCodeReviewRunOptions,
|
||||
input: AoCore.ExecuteCodeReviewRunInput,
|
||||
) =>
|
||||
actual.executeCodeReviewRun(
|
||||
{
|
||||
...options,
|
||||
storeFactory: options.storeFactory ?? createIsolatedStore,
|
||||
},
|
||||
input,
|
||||
),
|
||||
sendCodeReviewFindingsToAgent: (
|
||||
options: AoCore.SendCodeReviewFindingsOptions,
|
||||
input: AoCore.SendCodeReviewFindingsInput,
|
||||
) =>
|
||||
actual.sendCodeReviewFindingsToAgent(
|
||||
{
|
||||
...options,
|
||||
storeFactory: options.storeFactory ?? createIsolatedStore,
|
||||
},
|
||||
input,
|
||||
),
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
|
||||
}));
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z"));
|
||||
lifecycle.session.state = "idle";
|
||||
lifecycle.session.reason = "awaiting_external_review";
|
||||
lifecycle.pr.state = "open";
|
||||
lifecycle.pr.reason = "review_pending";
|
||||
lifecycle.pr.number = 7;
|
||||
lifecycle.pr.url = "https://github.com/acme/app/pull/7";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/todos",
|
||||
issueId: null,
|
||||
pr: {
|
||||
number: 7,
|
||||
url: "https://github.com/acme/app/pull/7",
|
||||
title: "feat: todos",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/todos",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date("2026-05-10T09:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGitRepo(path: string): void {
|
||||
mkdirSync(path, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path });
|
||||
writeFileSync(join(path, "README.md"), "# App\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: path });
|
||||
execFileSync("git", ["commit", "-m", "initial"], {
|
||||
cwd: path,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "AO Test",
|
||||
GIT_AUTHOR_EMAIL: "ao@example.com",
|
||||
GIT_COMMITTER_NAME: "AO Test",
|
||||
GIT_COMMITTER_EMAIL: "ao@example.com",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let originalHome: string | undefined;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerReview } from "../../src/commands/review.js";
|
||||
|
||||
let program: Command;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-cli-review-test-"));
|
||||
reviewStoreRootRef.current = join(tmpDir, "review-store");
|
||||
originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
|
||||
mockConfigRef.current = {
|
||||
configPath: join(tmpDir, "agent-orchestrator.yaml"),
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
app: {
|
||||
name: "App",
|
||||
path: join(tmpDir, "app"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
docs: {
|
||||
name: "Docs",
|
||||
path: join(tmpDir, "docs"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "docs",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
reactions: {},
|
||||
};
|
||||
|
||||
const appPath = join(tmpDir, "app");
|
||||
createGitRepo(appPath);
|
||||
createGitRepo(join(tmpDir, "docs"));
|
||||
|
||||
mockSessionManager.get.mockReset();
|
||||
mockSessionManager.get.mockResolvedValue(makeSession({ workspacePath: appPath }));
|
||||
mockSessionManager.list.mockReset();
|
||||
mockSessionManager.send.mockReset();
|
||||
|
||||
createCodeReviewStore("app").deleteAll();
|
||||
createCodeReviewStore("docs").deleteAll();
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerReview(program);
|
||||
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("review command", () => {
|
||||
it("requests and lists review runs through the CLI", async () => {
|
||||
await program.parseAsync(["node", "test", "review", "run", "app-1", "--json"]);
|
||||
|
||||
const runPayload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { linkedSessionId: string; reviewerSessionId: string; status: string };
|
||||
};
|
||||
expect(runPayload.run).toMatchObject({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
consoleLogSpy.mockClear();
|
||||
await program.parseAsync(["node", "test", "review", "list", "app", "--json"]);
|
||||
|
||||
const listPayload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
runs: Array<{ linkedSessionId: string; reviewerSessionId: string }>;
|
||||
};
|
||||
expect(listPayload.runs).toHaveLength(1);
|
||||
expect(listPayload.runs[0]).toMatchObject({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown review run statuses", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "review", "run", "app-1", "--status", "bogus"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
});
|
||||
|
||||
it("can request and execute a review run directly", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"review",
|
||||
"run",
|
||||
"app-1",
|
||||
"--execute",
|
||||
"--command",
|
||||
`printf '%s\\n' '{"findings":[{"severity":"warning","title":"CLI finding","body":"Detected in CLI."}]}'`,
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { status: string; findingCount: number; openFindingCount: number };
|
||||
};
|
||||
expect(payload.run).toMatchObject({
|
||||
status: "needs_triage",
|
||||
findingCount: 1,
|
||||
openFindingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("executes the oldest queued review run across all projects when no run is specified", async () => {
|
||||
const appStore = createCodeReviewStore("app");
|
||||
const docsStore = createCodeReviewStore("docs");
|
||||
const appPath = join(tmpDir, "app");
|
||||
const docsPath = join(tmpDir, "docs");
|
||||
mockSessionManager.get.mockImplementation(async (sessionId: string) => {
|
||||
if (sessionId === "docs-1") {
|
||||
return makeSession({ id: "docs-1", projectId: "docs", workspacePath: docsPath });
|
||||
}
|
||||
if (sessionId === "app-1") {
|
||||
return makeSession({ id: "app-1", projectId: "app", workspacePath: appPath });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const older = docsStore.createRun(
|
||||
{
|
||||
linkedSessionId: "docs-1",
|
||||
reviewerSessionId: "docs-rev-1",
|
||||
status: "queued",
|
||||
},
|
||||
new Date("2026-05-10T10:00:00.000Z"),
|
||||
);
|
||||
const newer = appStore.createRun(
|
||||
{
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
},
|
||||
new Date("2026-05-10T11:00:00.000Z"),
|
||||
);
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"review",
|
||||
"execute",
|
||||
"--command",
|
||||
`printf '%s\\n' '{"findings":[]}'`,
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { id: string; reviewerSessionId: string; status: string };
|
||||
};
|
||||
expect(payload.run).toMatchObject({
|
||||
id: older.id,
|
||||
reviewerSessionId: "docs-rev-1",
|
||||
status: "clean",
|
||||
});
|
||||
expect(createCodeReviewStore("app").getRun(newer.id)?.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("sends open review findings to the linked coding worker", async () => {
|
||||
const store = createCodeReviewStore("app");
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
prNumber: 7,
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "warning",
|
||||
title: "CLI finding",
|
||||
body: "Detected in CLI.",
|
||||
filePath: "src/app.ts",
|
||||
startLine: 12,
|
||||
});
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"review",
|
||||
"send",
|
||||
"app-rev-1",
|
||||
"--project",
|
||||
"app",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const payload = JSON.parse(String(consoleLogSpy.mock.calls.at(-1)?.[0])) as {
|
||||
run: { status: string; openFindingCount: number; sentFindingCount: number };
|
||||
sentFindingCount: number;
|
||||
message: string;
|
||||
};
|
||||
expect(payload).toMatchObject({
|
||||
sentFindingCount: 1,
|
||||
run: {
|
||||
status: "waiting_update",
|
||||
openFindingCount: 0,
|
||||
sentFindingCount: 1,
|
||||
},
|
||||
});
|
||||
expect(payload.message).toContain("AO reviewer app-rev-1 found 1 open issue");
|
||||
expect(payload.message).toContain("Location: src/app.ts:12");
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
expect.stringContaining("[warning] CLI finding"),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,25 +2,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { type Session, type SessionManager, getProjectBaseDir } from "@aoagents/ao-core";
|
||||
import {
|
||||
recordActivityEvent,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
getProjectBaseDir,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(
|
||||
() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
}),
|
||||
);
|
||||
const { mockExec, mockConfigRef, mockSessionManager, mockGetRunning } = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
mockConfigRef: { current: null as Record<string, unknown> | null },
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
get: vi.fn(),
|
||||
spawn: vi.fn(),
|
||||
spawnOrchestrator: vi.fn(),
|
||||
send: vi.fn(),
|
||||
claimPR: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
tmux: vi.fn(),
|
||||
|
|
@ -49,6 +52,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
return {
|
||||
...actual,
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -86,6 +90,9 @@ import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js";
|
|||
let program: Command;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-spawn-test-"));
|
||||
configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
|
|
@ -134,6 +141,7 @@ beforeEach(() => {
|
|||
mockSessionManager.claimPR.mockReset();
|
||||
mockExec.mockReset();
|
||||
mockGetRunning.mockReset();
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockRegistryGet.mockReset().mockReturnValue(null);
|
||||
mockGetRunning.mockResolvedValue({ pid: 1234, port: 3000, startedAt: "", projects: ["my-app"] });
|
||||
});
|
||||
|
|
@ -534,9 +542,7 @@ describe("spawn command", () => {
|
|||
it("reports error when spawn fails", async () => {
|
||||
mockSessionManager.spawn.mockRejectedValue(new Error("worktree creation failed"));
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
|
||||
});
|
||||
|
||||
it("claims a PR for the spawned session when --claim-pr is provided", async () => {
|
||||
|
|
@ -628,14 +634,7 @@ describe("spawn command", () => {
|
|||
takenOverFrom: ["app-9"],
|
||||
});
|
||||
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"test",
|
||||
"spawn",
|
||||
"--claim-pr",
|
||||
"123",
|
||||
"--assign-on-github",
|
||||
]);
|
||||
await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123", "--assign-on-github"]);
|
||||
|
||||
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
|
||||
assignOnGithub: true,
|
||||
|
|
@ -736,6 +735,19 @@ describe("spawn pre-flight checks", () => {
|
|||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("tmux is not installed");
|
||||
expect(recordedEvents()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.spawn_failed",
|
||||
source: "cli",
|
||||
projectId: "my-app",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
issueId: null,
|
||||
agent: null,
|
||||
errorMessage: "tmux is not installed. Install it: brew install tmux",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -860,7 +872,9 @@ describe("batch-spawn command", () => {
|
|||
return cmd;
|
||||
}
|
||||
|
||||
function makeFakeSession(overrides: Partial<Session> & Pick<Session, "id" | "projectId">): Session {
|
||||
function makeFakeSession(
|
||||
overrides: Partial<Session> & Pick<Session, "id" | "projectId">,
|
||||
): Session {
|
||||
return {
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,571 @@
|
|||
/**
|
||||
* Tests for start.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Covers MUST emits in registerStop and the start action that don't
|
||||
* require running the full startup pipeline:
|
||||
* - cli.stop_invoked (start of ao stop action)
|
||||
* - cli.stop_failed (outer catch of ao stop action)
|
||||
* - cli.stop_session_failed (per-session kill failure during ao stop)
|
||||
* - cli.last_stop_write_failed (last-stop persistence failure during ao stop)
|
||||
* - cli.daemon_killed (SIGTERM sent to parent ao start)
|
||||
* - cli.start_invoked (true start action entry)
|
||||
* - cli.start_failed (outer) (outer catch of ao start action)
|
||||
* - cli.restore_session_failed (per-session restore failure)
|
||||
*
|
||||
* cli.start_failed (orchestrator_setup / supervisor_start) is exercised by
|
||||
* the existing start.test.ts infrastructure; this file
|
||||
* focuses on emits that are reachable with a small deps surface.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockSessionManager,
|
||||
mockGetRunning,
|
||||
mockUnregister,
|
||||
mockWriteLastStop,
|
||||
mockReadLastStop,
|
||||
mockClearLastStop,
|
||||
mockAcquireStartupLock,
|
||||
mockIsAlreadyRunning,
|
||||
mockFindPidByPort,
|
||||
mockKillProcessTree,
|
||||
mockIsWindows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSessionManager: {
|
||||
list: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
ensureOrchestrator: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
mockGetRunning: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
mockWriteLastStop: vi.fn(),
|
||||
mockReadLastStop: vi.fn(),
|
||||
mockClearLastStop: vi.fn(),
|
||||
mockAcquireStartupLock: vi.fn(),
|
||||
mockIsAlreadyRunning: vi.fn(),
|
||||
mockFindPidByPort: vi.fn(),
|
||||
mockKillProcessTree: vi.fn(),
|
||||
mockIsWindows: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async () => mockSessionManager,
|
||||
getPluginRegistry: async () => ({ register: vi.fn(), get: () => null }),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
acquireStartupLock: (...args: unknown[]) => mockAcquireStartupLock(...args),
|
||||
isAlreadyRunning: (...args: unknown[]) => mockIsAlreadyRunning(...args),
|
||||
getRunning: (...args: unknown[]) => mockGetRunning(...args),
|
||||
register: vi.fn(),
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
removeProjectFromRunning: vi.fn(),
|
||||
addProjectToRunning: vi.fn(),
|
||||
writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args),
|
||||
readLastStop: (...args: unknown[]) => mockReadLastStop(...args),
|
||||
clearLastStop: (...args: unknown[]) => mockClearLastStop(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/lifecycle-service.js", () => ({
|
||||
stopAllLifecycleWorkers: vi.fn(),
|
||||
listLifecycleWorkers: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-supervisor.js", () => ({
|
||||
startProjectSupervisor: vi.fn(),
|
||||
stopProjectSupervisor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/preflight.js", () => ({
|
||||
preflight: { checkPort: vi.fn(), checkBuilt: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
findWebDir: vi.fn().mockReturnValue("/fake/web"),
|
||||
buildDashboardEnv: vi.fn().mockResolvedValue({}),
|
||||
waitForPortAndOpen: vi.fn(),
|
||||
openUrl: vi.fn(),
|
||||
isPortAvailable: vi.fn().mockResolvedValue(true),
|
||||
findFreePort: vi.fn().mockResolvedValue(3000),
|
||||
MAX_PORT_SCAN: 100,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
|
||||
clearStaleCacheIfNeeded: vi.fn(),
|
||||
rebuildDashboardProductionArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
exec: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
execSilent: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
git: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({
|
||||
startBunTmpJanitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/daemon.js", () => ({
|
||||
attachToDaemon: vi.fn(),
|
||||
killExistingDaemon: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/caller-context.js", () => ({
|
||||
isHumanCaller: () => false,
|
||||
getCallerType: () => "automation",
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/detect-env.js", () => ({
|
||||
detectEnvironment: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/detect-agent.js", () => ({
|
||||
detectAgentRuntime: vi.fn(),
|
||||
detectAvailableAgents: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/git-utils.js", () => ({
|
||||
detectDefaultBranch: vi.fn().mockResolvedValue("main"),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/prompts.js", () => ({
|
||||
promptConfirm: vi.fn().mockResolvedValue(false),
|
||||
promptSelect: vi.fn(),
|
||||
promptText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/install-helpers.js", () => ({
|
||||
canPromptForInstall: vi.fn().mockReturnValue(false),
|
||||
genericInstallHints: vi.fn().mockReturnValue([]),
|
||||
askYesNo: vi.fn().mockResolvedValue(false),
|
||||
runInteractiveCommand: vi.fn(),
|
||||
tryInstallWithAttempts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/startup-preflight.js", () => ({
|
||||
ensureGit: vi.fn(),
|
||||
runtimePreflight: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shutdown.js", () => ({
|
||||
installShutdownHandlers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/resolve-project.js", () => ({
|
||||
resolveOrCreateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-resolution.js", () => ({
|
||||
findProjectForDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/repo-utils.js", () => ({
|
||||
extractOwnerRepo: vi.fn(),
|
||||
isValidRepoString: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-detection.js", () => ({
|
||||
detectProjectType: vi.fn(),
|
||||
generateRulesFromTemplates: vi.fn(),
|
||||
formatProjectTypeForDisplay: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/cli-errors.js", () => ({
|
||||
formatCommandError: vi.fn((err: unknown) => String(err)),
|
||||
}));
|
||||
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
import { registerStart, registerStop } from "../../src/commands/start.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("ao stop — activity events", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockGetRunning.mockReset();
|
||||
mockSessionManager.list.mockReset();
|
||||
mockSessionManager.kill.mockReset();
|
||||
mockUnregister.mockReset();
|
||||
mockWriteLastStop.mockReset();
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockFindPidByPort.mockReset();
|
||||
mockFindPidByPort.mockResolvedValue(null);
|
||||
mockKillProcessTree.mockReset();
|
||||
mockKillProcessTree.mockResolvedValue(undefined);
|
||||
mockIsWindows.mockReset();
|
||||
mockIsWindows.mockReturnValue(false);
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits cli.stop_invoked at the start of the action", async () => {
|
||||
const projectArg = "https://token@example.com/org/repo.git";
|
||||
// Force a fast failure so the action exits quickly after emitting stop_invoked.
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
// Make loadConfig throw so we hit the outer catch
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => {
|
||||
throw new Error("config not found");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop", projectArg])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_invoked",
|
||||
source: "cli",
|
||||
summary: "ao stop invoked",
|
||||
data: expect.objectContaining({
|
||||
projectArg,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.stop_failed when loadConfig throws", async () => {
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => {
|
||||
throw new Error("config blew up");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "stop"])).rejects.toThrow();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ errorMessage: "config blew up" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.daemon_killed when SIGTERM is sent to a running daemon", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([]);
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "stop"]);
|
||||
} catch {
|
||||
// ao stop may exit; we just want the events
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.daemon_killed",
|
||||
source: "cli",
|
||||
data: expect.objectContaining({ pid: 99999 }),
|
||||
}),
|
||||
);
|
||||
expect(mockKillProcessTree).toHaveBeenCalledWith(99999, "SIGTERM");
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.stop_session_failed when sm.kill throws during ao stop", async () => {
|
||||
mockGetRunning.mockResolvedValue({
|
||||
pid: 99999,
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
startedAt: new Date().toISOString(),
|
||||
projects: ["my-app"],
|
||||
});
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockSessionManager.kill.mockRejectedValue(new Error("kill timeout"));
|
||||
vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
// isTerminalSession returns false so the session is treated as active
|
||||
isTerminalSession: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "stop"]);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_session_failed",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
sessionId: "sess-1",
|
||||
data: expect.objectContaining({ errorMessage: "kill timeout" }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
|
||||
it("emits cli.last_stop_write_failed when ao stop cannot persist restore state", async () => {
|
||||
mockGetRunning.mockResolvedValue(null);
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
{
|
||||
id: "sess-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockSessionManager.kill.mockResolvedValue({ cleaned: true, alreadyTerminated: false });
|
||||
mockWriteLastStop.mockRejectedValue(new Error("last-stop lock busy"));
|
||||
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
findPidByPort: (...args: unknown[]) => mockFindPidByPort(...args),
|
||||
isWindows: (...args: unknown[]) => mockIsWindows(...args),
|
||||
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
|
||||
recordActivityEvent: vi.mocked(recordActivityEvent),
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/x.yaml",
|
||||
port: 3000,
|
||||
projects: { "my-app": { name: "my-app", path: "/tmp/my-app" } },
|
||||
defaults: {},
|
||||
}),
|
||||
// isTerminalSession returns false so the session is treated as active
|
||||
isTerminalSession: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await import("../../src/commands/start.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
reloaded.registerStop(program);
|
||||
|
||||
await program.parseAsync(["node", "ao", "stop", "my-app"]);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.last_stop_write_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
projectId: "my-app",
|
||||
data: expect.objectContaining({
|
||||
targetSessionCount: 1,
|
||||
totalKilled: 1,
|
||||
errorMessage: "last-stop lock busy",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.last_stop_written",
|
||||
}),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.stop_failed",
|
||||
}),
|
||||
);
|
||||
const logs = vi.mocked(console.log).mock.calls.map((c) => String(c[0]));
|
||||
expect(logs.some((line) => line.includes("Could not list sessions"))).toBe(false);
|
||||
expect(logs.some((line) => line.includes("Could not write last-stop state"))).toBe(true);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ao start — activity events (failure paths)", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockAcquireStartupLock.mockReset();
|
||||
mockIsAlreadyRunning.mockReset();
|
||||
mockAcquireStartupLock.mockResolvedValue(() => undefined);
|
||||
mockIsAlreadyRunning.mockResolvedValue(null);
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits cli.start_failed with reason 'outer' when resolveOrCreateProject throws", async () => {
|
||||
const projectArg = "https://token@example.com/org/repo.git";
|
||||
const resolveProjectMod = await import("../../src/lib/resolve-project.js");
|
||||
vi.mocked(resolveProjectMod.resolveOrCreateProject).mockRejectedValue(
|
||||
new Error("project resolution exploded"),
|
||||
);
|
||||
|
||||
const program = buildProgram();
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "ao", "start", projectArg]);
|
||||
} catch {
|
||||
// process.exit(1) throws in the spy
|
||||
}
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.start_invoked",
|
||||
source: "cli",
|
||||
level: "info",
|
||||
summary: "ao start invoked",
|
||||
data: expect.objectContaining({
|
||||
projectArg,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.start_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
reason: "outer",
|
||||
errorMessage: "project resolution exploded",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { SessionManager } from "@aoagents/ao-core";
|
||||
import { recordActivityEvent, type SessionManager } from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
|
|
@ -153,6 +153,7 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
sweepDaemonChildren: mockSweepDaemonChildren,
|
||||
scanAoOrphans: mockScanAoOrphans,
|
||||
reapAoOrphans: mockReapAoOrphans,
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -336,6 +337,7 @@ beforeEach(async () => {
|
|||
program.exitOverride();
|
||||
registerStart(program);
|
||||
registerStop(program);
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
|
@ -505,6 +507,9 @@ function makeProject(overrides: Record<string, unknown> = {}): Record<string, un
|
|||
};
|
||||
}
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
/** Mock process.cwd() to return a specific directory (avoids process.chdir in workers). */
|
||||
function mockCwd(dir: string): void {
|
||||
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir);
|
||||
|
|
@ -1571,6 +1576,19 @@ describe("start command — orchestrator session strategy display", () => {
|
|||
await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
expect(releaseStartupLock).toHaveBeenCalledTimes(1);
|
||||
const startFailedEvents = recordedEvents().filter((e) => e.kind === "cli.start_failed");
|
||||
expect(startFailedEvents).toHaveLength(1);
|
||||
expect(startFailedEvents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "my-app",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
reason: "orchestrator_setup",
|
||||
errorMessage: "Spawn failed",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails and cleans up dashboard when sm.restore throws on a killed orchestrator", async () => {
|
||||
|
|
@ -1651,6 +1669,56 @@ describe("start command — orchestrator session strategy display", () => {
|
|||
expect(written.stoppedAt).toBe("2026-04-28T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("attributes other-project restore failures to the owning project", async () => {
|
||||
mockReadLastStop.mockResolvedValue({
|
||||
stoppedAt: "2026-04-28T10:00:00.000Z",
|
||||
projectId: "my-app",
|
||||
sessionIds: ["app-1"],
|
||||
otherProjects: [{ projectId: "other-app", sessionIds: ["other-1"] }],
|
||||
});
|
||||
|
||||
mockConfigRef.current = makeConfig({
|
||||
"my-app": makeProject(),
|
||||
"other-app": makeProject({ name: "Other App", sessionPrefix: "other" }),
|
||||
});
|
||||
const { findWebDir } = await import("../../src/lib/web-dir.js");
|
||||
vi.mocked(findWebDir).mockReturnValue(tmpDir);
|
||||
writeFileSync(join(tmpDir, "package.json"), "{}");
|
||||
|
||||
const fakeDashboard = { on: vi.fn(), kill: vi.fn(), emit: vi.fn() };
|
||||
mockSpawn.mockReturnValue(fakeDashboard);
|
||||
|
||||
mockSessionManager.restore.mockImplementation((id: string) => {
|
||||
if (id === "other-1") return Promise.reject(new Error("workspace gone"));
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "start", "my-app", "--no-orchestrator"]);
|
||||
|
||||
const restoreFailedEvents = recordedEvents().filter(
|
||||
(e) => e.kind === "cli.restore_session_failed",
|
||||
);
|
||||
expect(restoreFailedEvents).toHaveLength(1);
|
||||
expect(restoreFailedEvents[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "other-app",
|
||||
sessionId: "other-1",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({ errorMessage: "workspace gone" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const written = mockWriteLastStop.mock.calls[0][0];
|
||||
expect(written).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "my-app",
|
||||
sessionIds: [],
|
||||
otherProjects: [{ projectId: "other-app", sessionIds: ["other-1"] }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears last-stop record when every session restored successfully", async () => {
|
||||
mockReadLastStop.mockResolvedValue({
|
||||
stoppedAt: "2026-04-28T10:00:00.000Z",
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ import {
|
|||
type ActivityState,
|
||||
createInitialCanonicalLifecycle,
|
||||
createActivitySignal,
|
||||
createCodeReviewStore,
|
||||
sessionFromMetadata,
|
||||
} from "@aoagents/ao-core";
|
||||
import type * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const {
|
||||
mockTmux,
|
||||
|
|
@ -32,6 +34,7 @@ const {
|
|||
mockSessionManager,
|
||||
mockGetPluginRegistry,
|
||||
sessionsDirRef,
|
||||
reviewStoreRootRef,
|
||||
} = vi.hoisted(() => ({
|
||||
mockTmux: vi.fn(),
|
||||
mockGit: vi.fn(),
|
||||
|
|
@ -54,6 +57,7 @@ const {
|
|||
},
|
||||
mockGetPluginRegistry: vi.fn(),
|
||||
sessionsDirRef: { current: "" },
|
||||
reviewStoreRootRef: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
|
|
@ -76,10 +80,17 @@ vi.mock("../../src/lib/shell.js", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
createCodeReviewStore: (
|
||||
projectId: string,
|
||||
options: AoCore.CodeReviewStoreOptions = {},
|
||||
) =>
|
||||
actual.createCodeReviewStore(projectId, {
|
||||
...options,
|
||||
storeDir: options.storeDir ?? `${reviewStoreRootRef.current}/${projectId}`,
|
||||
}),
|
||||
loadConfig: () => mockConfigRef.current,
|
||||
};
|
||||
});
|
||||
|
|
@ -211,6 +222,7 @@ vi.mock("../../src/lib/create-session-manager.js", () => ({
|
|||
|
||||
let tmpDir: string;
|
||||
let sessionsDir: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerStatus } from "../../src/commands/status.js";
|
||||
|
|
@ -223,6 +235,8 @@ let processOnceSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-"));
|
||||
originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
|
||||
const configPath = join(tmpDir, "agent-orchestrator.yaml");
|
||||
writeFileSync(configPath, "projects: {}");
|
||||
|
|
@ -256,6 +270,7 @@ beforeEach(() => {
|
|||
sessionsDir = join(tmpDir, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
sessionsDirRef.current = sessionsDir;
|
||||
reviewStoreRootRef.current = join(tmpDir, "code-reviews");
|
||||
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
|
|
@ -296,6 +311,11 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
setIntervalSpy?.mockRestore();
|
||||
setIntervalSpy = undefined;
|
||||
clearIntervalSpy?.mockRestore();
|
||||
|
|
@ -326,6 +346,67 @@ describe("status command", () => {
|
|||
expect(output).toContain("no active sessions");
|
||||
});
|
||||
|
||||
it("shows AO-local reviewer runs separately from coding sessions", async () => {
|
||||
const store = createCodeReviewStore("my-app");
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
summary: "Review run",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "warning",
|
||||
title: "Review finding",
|
||||
body: "Finding body",
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("Reviews:");
|
||||
expect(output).toContain("app-rev-1");
|
||||
expect(output).toContain("needs_triage");
|
||||
expect(output).toContain("app-1");
|
||||
expect(output).toContain("1 open finding");
|
||||
});
|
||||
|
||||
it("includes reviewer runs in JSON status output", async () => {
|
||||
const store = createCodeReviewStore("my-app");
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "warning",
|
||||
title: "Review finding",
|
||||
body: "Finding body",
|
||||
});
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--json"]);
|
||||
|
||||
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
|
||||
const parsed = JSON.parse(jsonCalls) as {
|
||||
reviews: Array<{ reviewerSessionId: string; linkedSessionId: string; openFindingCount: number }>;
|
||||
meta: { reviewRunCount: number; activeReviewRunCount: number; openReviewFindingCount: number };
|
||||
};
|
||||
expect(parsed.reviews).toHaveLength(1);
|
||||
expect(parsed.reviews[0]).toMatchObject({
|
||||
reviewerSessionId: "app-rev-1",
|
||||
linkedSessionId: "app-1",
|
||||
openFindingCount: 1,
|
||||
});
|
||||
expect(parsed.meta).toMatchObject({
|
||||
reviewRunCount: 1,
|
||||
activeReviewRunCount: 1,
|
||||
openReviewFindingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("displays sessions from tmux with metadata", async () => {
|
||||
// Create metadata files
|
||||
writeFileSync(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Tests for update.ts activity-event instrumentation (issue #1654).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { EventEmitter } from "node:events";
|
||||
import * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
const { mockRunRepoScript } = vi.hoisted(() => ({
|
||||
mockRunRepoScript: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/script-runner.js", () => ({
|
||||
runRepoScript: (...args: unknown[]) => mockRunRepoScript(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
mockDetectInstallMethod,
|
||||
mockCheckForUpdate,
|
||||
mockInvalidateCache,
|
||||
mockGetCurrentVersion,
|
||||
mockGetUpdateCommand,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDetectInstallMethod: vi.fn(() => "git" as const),
|
||||
mockCheckForUpdate: vi.fn(),
|
||||
mockInvalidateCache: vi.fn(),
|
||||
mockGetCurrentVersion: vi.fn(() => "0.2.2"),
|
||||
mockGetUpdateCommand: vi.fn(() => "npm install -g @aoagents/ao@latest"),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/update-check.js", () => ({
|
||||
detectInstallMethod: () => mockDetectInstallMethod(),
|
||||
checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args),
|
||||
invalidateCache: () => mockInvalidateCache(),
|
||||
getCurrentVersion: () => mockGetCurrentVersion(),
|
||||
getUpdateCommand: (...args: unknown[]) => mockGetUpdateCommand(...args),
|
||||
readCachedUpdateInfo: vi.fn(() => undefined),
|
||||
resolveUpdateChannel: vi.fn(() => "stable"),
|
||||
}));
|
||||
|
||||
const { mockPromptConfirm } = vi.hoisted(() => ({
|
||||
mockPromptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/prompts.js", () => ({
|
||||
promptConfirm: (...args: unknown[]) => mockPromptConfirm(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
getRunning: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual("node:child_process");
|
||||
return { ...actual, spawn: (...args: unknown[]) => mockSpawn(...args) };
|
||||
});
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
getGlobalConfigPath: () => "/tmp/__ao_update_instrumentation_no_global_config__",
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerUpdate } from "../../src/commands/update.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
function createMockChild(exitCode: number | null, signal?: NodeJS.Signals): EventEmitter {
|
||||
const child = new EventEmitter();
|
||||
setTimeout(() => child.emit("exit", exitCode, signal ?? null), 0);
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("ao update — activity events", () => {
|
||||
let program: Command;
|
||||
let origStdinTTY: boolean | undefined;
|
||||
let origStdoutTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(AoCore.recordActivityEvent).mockClear();
|
||||
program = new Command();
|
||||
program.exitOverride();
|
||||
registerUpdate(program);
|
||||
mockRunRepoScript.mockReset();
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockCheckForUpdate.mockReset();
|
||||
mockInvalidateCache.mockReset();
|
||||
mockPromptConfirm.mockReset();
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockSpawn.mockReset();
|
||||
origStdinTTY = process.stdin.isTTY;
|
||||
origStdoutTTY = process.stdout.isTTY;
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: origStdinTTY, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: origStdoutTTY, configurable: true });
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when ao-update.sh exits non-zero (git path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockRunRepoScript.mockResolvedValue(2);
|
||||
|
||||
// process.exit is mocked to throw — the first `process.exit(2)` triggers
|
||||
// the throw, which is then re-caught and emits a second event before the
|
||||
// final exit. The instrumentation event for the non-zero exit is what
|
||||
// matters; whichever final exit code propagates is incidental.
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(/process\.exit/);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "git", exitCode: 2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when ao-update.sh script is missing (git path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("git");
|
||||
mockRunRepoScript.mockRejectedValue(new Error("Script not found: ao-update.sh"));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "git", reason: "script_missing" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm install exits non-zero (npm path)", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue({
|
||||
currentVersion: "0.2.2",
|
||||
latestVersion: "0.3.0",
|
||||
isOutdated: true,
|
||||
installMethod: "npm-global" as const,
|
||||
recommendedCommand: "npm install -g @aoagents/ao@latest",
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
mockSpawn.mockReturnValue(createMockChild(1));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ method: "npm-global", exitCode: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm registry lookup returns no version", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockResolvedValue({
|
||||
currentVersion: "0.2.2",
|
||||
latestVersion: null,
|
||||
isOutdated: false,
|
||||
installMethod: "npm-global" as const,
|
||||
recommendedCommand: "npm install -g @aoagents/ao@latest",
|
||||
checkedAt: null,
|
||||
});
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
method: "npm-global",
|
||||
reason: "registry_unreachable",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.update_failed when npm registry lookup throws", async () => {
|
||||
mockDetectInstallMethod.mockReturnValue("npm-global");
|
||||
mockCheckForUpdate.mockRejectedValue(new Error("registry timeout"));
|
||||
|
||||
await expect(program.parseAsync(["node", "ao", "update"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.update_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
method: "npm-global",
|
||||
reason: "registry_lookup_threw",
|
||||
errorMessage: "registry timeout",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
closeDb,
|
||||
readObservabilitySummary,
|
||||
type Notifier,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorEvent,
|
||||
type PluginRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
addSinkNotifierConfig,
|
||||
createNotifyTestEvent,
|
||||
parseNotifyDataJson,
|
||||
resolveNotifyTestTargets,
|
||||
runNotifyTest,
|
||||
startNotifySink,
|
||||
} from "../../src/lib/notify-test.js";
|
||||
|
||||
function makeConfig(overrides: Partial<OrchestratorConfig> = {}): OrchestratorConfig {
|
||||
const testHome = process.env["HOME"] ?? process.env["USERPROFILE"] ?? tmpdir();
|
||||
return {
|
||||
configPath: join(testHome, "agent-orchestrator.yaml"),
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts"],
|
||||
},
|
||||
projects: {
|
||||
demo: {
|
||||
name: "Demo",
|
||||
path: "/tmp/demo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "demo",
|
||||
},
|
||||
},
|
||||
notifiers: {
|
||||
alerts: { plugin: "slack" },
|
||||
ops: { plugin: "slack" },
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["alerts", "ops"],
|
||||
warning: ["ops"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
reactions: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(notifiers: Record<string, Partial<Notifier> | undefined>): PluginRegistry {
|
||||
return {
|
||||
register: vi.fn(),
|
||||
get: vi.fn((slot: string, name: string) => {
|
||||
if (slot !== "notifier") return null;
|
||||
return notifiers[name] ?? null;
|
||||
}),
|
||||
list: vi.fn(() => []),
|
||||
loadBuiltins: vi.fn(),
|
||||
loadFromConfig: vi.fn(),
|
||||
} as unknown as PluginRegistry;
|
||||
}
|
||||
|
||||
describe("notify test helper", () => {
|
||||
let tempRoot: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(tmpdir(), `ao-notify-test-${randomUUID()}`);
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
originalHome = process.env["HOME"];
|
||||
originalUserProfile = process.env["USERPROFILE"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["USERPROFILE"] = tempRoot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env["USERPROFILE"];
|
||||
} else {
|
||||
process.env["USERPROFILE"] = originalUserProfile;
|
||||
}
|
||||
rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it("builds realistic CI and PR template data", () => {
|
||||
const { event } = createNotifyTestEvent({ templateName: "ci-failing" });
|
||||
|
||||
expect(event.type).toBe("ci.failing");
|
||||
expect(event.priority).toBe("action");
|
||||
expect(event.data).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
semanticType: "ci.failing",
|
||||
subject: {
|
||||
pr: {
|
||||
number: 1579,
|
||||
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
status: "failing",
|
||||
failedChecks: [
|
||||
{ name: "typecheck", status: "failed" },
|
||||
{ name: "unit-tests", status: "failed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(event.data.prUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges valid --data JSON and rejects invalid JSON", () => {
|
||||
expect(parseNotifyDataJson('{"runId":"abc","attempt":2}')).toEqual({
|
||||
runId: "abc",
|
||||
attempt: 2,
|
||||
});
|
||||
expect(() => parseNotifyDataJson("{bad json")).toThrow("Invalid --data JSON");
|
||||
expect(() => parseNotifyDataJson('"not an object"')).toThrow("--data must be a JSON object");
|
||||
});
|
||||
|
||||
it("resolves aliases and falls back to plugin-name registry lookup", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
slack: { name: "slack", notify },
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const result = await runNotifyTest(config, registry, { to: ["alerts"] });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.targets).toEqual([{ reference: "alerts", pluginName: "slack" }]);
|
||||
expect(registry.get).toHaveBeenCalledWith("notifier", "alerts");
|
||||
expect(registry.get).toHaveBeenCalledWith("notifier", "slack");
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["demo"]?.metrics["notification_delivery"]?.success).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves explicit routes through notificationRouting before defaults", () => {
|
||||
const targets = resolveNotifyTestTargets(makeConfig(), "info", { route: "action" });
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ reference: "alerts", pluginName: "slack" },
|
||||
{ reference: "ops", pluginName: "slack" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates --all targets across configured, default, and routed refs", () => {
|
||||
const config = makeConfig({
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["alerts", "ops"],
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["alerts"],
|
||||
action: ["ops"],
|
||||
warning: ["alerts", "ops"],
|
||||
info: ["alerts"],
|
||||
},
|
||||
});
|
||||
|
||||
const targets = resolveNotifyTestTargets(config, "info", { all: true });
|
||||
|
||||
expect(targets.map((target) => target.reference)).toEqual(["alerts", "ops"]);
|
||||
});
|
||||
|
||||
it("does not send in dry-run mode", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(makeConfig(), registry, { dryRun: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.deliveries[0]?.status).toBe("dry_run");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(readObservabilitySummary(makeConfig()).projects["demo"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses notifyWithActions when available", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const notifyWithActions = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify, notifyWithActions },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(makeConfig(), registry, { actions: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.deliveries[0]?.method).toBe("notifyWithActions");
|
||||
expect(notifyWithActions).toHaveBeenCalledTimes(1);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns and falls back to notify when actions are unsupported", async () => {
|
||||
const notify = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(makeConfig(), registry, { actions: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.deliveries[0]?.method).toBe("notify");
|
||||
expect(result.warnings[0]).toContain("notifyWithActions() is unavailable");
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues after partial delivery failures", async () => {
|
||||
const failing = vi.fn().mockRejectedValue(new Error("webhook failed"));
|
||||
const passing = vi.fn().mockResolvedValue(undefined);
|
||||
const registry = makeRegistry({
|
||||
alerts: { name: "alerts", notify: failing },
|
||||
ops: { name: "ops", notify: passing },
|
||||
});
|
||||
|
||||
const config = makeConfig();
|
||||
const result = await runNotifyTest(config, registry, { route: "action" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(failing).toHaveBeenCalledTimes(1);
|
||||
expect(passing).toHaveBeenCalledTimes(1);
|
||||
expect(result.deliveries.map((delivery) => delivery.status)).toEqual(["failed", "sent"]);
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["demo"]?.metrics["notification_delivery"]).toMatchObject({
|
||||
success: 1,
|
||||
failure: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports unresolved targets and no-target configs as failures", async () => {
|
||||
const unresolved = await runNotifyTest(makeConfig(), makeRegistry({}), { to: ["missing"] });
|
||||
expect(unresolved.ok).toBe(false);
|
||||
expect(unresolved.deliveries[0]?.status).toBe("unresolved");
|
||||
|
||||
const noTargets = await runNotifyTest(
|
||||
makeConfig({
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: [],
|
||||
action: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
}),
|
||||
makeRegistry({}),
|
||||
);
|
||||
expect(noTargets.ok).toBe(false);
|
||||
expect(noTargets.errors[0]).toContain("No notifier targets resolved");
|
||||
});
|
||||
|
||||
it("captures a local webhook sink payload", async () => {
|
||||
const sink = await startNotifySink(0);
|
||||
try {
|
||||
const config = addSinkNotifierConfig(makeConfig({ notifiers: {} }), sink.url);
|
||||
const notify = vi.fn(async (event: OrchestratorEvent) => {
|
||||
await fetch(sink.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "notification", event }),
|
||||
});
|
||||
});
|
||||
const registry = makeRegistry({
|
||||
sink: { name: "sink", notify },
|
||||
});
|
||||
|
||||
const result = await runNotifyTest(config, registry, { to: ["sink"] });
|
||||
const request = await sink.waitForRequest();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(request?.json).toMatchObject({
|
||||
type: "notification",
|
||||
event: {
|
||||
message: "Test notification from ao notify test",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,12 @@ const mockSetHealth = vi.fn();
|
|||
const activeWorkers = new Set<string>();
|
||||
|
||||
vi.mock("@aoagents/ao-core", () => ({
|
||||
ConfigNotFoundError: class ConfigNotFoundError extends Error {
|
||||
constructor(message = "No agent-orchestrator.yaml found.") {
|
||||
super(message);
|
||||
this.name = "ConfigNotFoundError";
|
||||
}
|
||||
},
|
||||
createCorrelationId: () => "correlation-id",
|
||||
createProjectObserver: () => ({ setHealth: (...args: unknown[]) => mockSetHealth(...args) }),
|
||||
getGlobalConfigPath: () => "/tmp/global-config.yaml",
|
||||
|
|
@ -67,9 +73,9 @@ import {
|
|||
stopProjectSupervisor,
|
||||
} from "../../src/lib/project-supervisor.js";
|
||||
|
||||
function makeConfig(projectIds: string[]) {
|
||||
function makeConfig(projectIds: string[], configPath = "/tmp/global-config.yaml") {
|
||||
return {
|
||||
configPath: "/tmp/global-config.yaml",
|
||||
configPath,
|
||||
projects: Object.fromEntries(projectIds.map((id) => [id, { name: id, path: `/tmp/${id}` }])),
|
||||
};
|
||||
}
|
||||
|
|
@ -223,7 +229,7 @@ describe("project-supervisor", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const startPromise = startProjectSupervisor(1_000);
|
||||
const startPromise = startProjectSupervisor({ intervalMs: 1_000 });
|
||||
await vi.waitFor(() => expect(releaseList).toBeDefined());
|
||||
|
||||
stopProjectSupervisor();
|
||||
|
|
@ -242,7 +248,7 @@ describe("project-supervisor", () => {
|
|||
throw new Error("bad config");
|
||||
});
|
||||
|
||||
await expect(startProjectSupervisor(1_000)).rejects.toThrow("bad config");
|
||||
await expect(startProjectSupervisor({ intervalMs: 1_000 })).rejects.toThrow("bad config");
|
||||
});
|
||||
|
||||
it("allows startup when the global config does not exist yet", async () => {
|
||||
|
|
@ -257,7 +263,7 @@ describe("project-supervisor", () => {
|
|||
throw error;
|
||||
});
|
||||
|
||||
const handle = await startProjectSupervisor(1_000);
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_000 });
|
||||
|
||||
expect(handle).toEqual({
|
||||
stop: expect.any(Function),
|
||||
|
|
@ -266,10 +272,230 @@ describe("project-supervisor", () => {
|
|||
handle.stop();
|
||||
});
|
||||
|
||||
it("falls back to local config when the global config is missing (ENOENT)", async () => {
|
||||
// The local fallback uses a DIFFERENT configPath than the global —
|
||||
// a real bare `loadConfig()` discovers the local file and sets
|
||||
// `config.configPath` to that path. Asserting on the local path here
|
||||
// catches any bug that would propagate the global path through the
|
||||
// fallback (e.g. accidentally returning the global config object).
|
||||
const localConfigPath = "/tmp/cwd/agent-orchestrator.yaml";
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
return makeConfig(["app"], localConfigPath);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor();
|
||||
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith();
|
||||
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configPath: localConfigPath }),
|
||||
"app",
|
||||
undefined,
|
||||
);
|
||||
expect(activeWorkers.has("app")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the caller-provided configPath as the local fallback when global is missing", async () => {
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
if (path === "/some/repo/agent-orchestrator.yaml") {
|
||||
return makeConfig(["app"]);
|
||||
}
|
||||
throw new Error(`unexpected loadConfig path: ${path}`);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor({ configPath: "/some/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/some/repo/agent-orchestrator.yaml");
|
||||
// No bare cwd-walk when the caller resolved a path for us.
|
||||
expect(mockLoadConfig).not.toHaveBeenCalledWith();
|
||||
expect(activeWorkers.has("app")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores the caller-provided configPath when the global config is healthy", async () => {
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
// Both paths would return a valid config — assert we only ever consult
|
||||
// the global path. The configPath is the fallback, not an override.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") return makeConfig(["app"]);
|
||||
if (path === "/repo/agent-orchestrator.yaml") {
|
||||
throw new Error("supervisor should not consult configPath when global is healthy");
|
||||
}
|
||||
throw new Error(`unexpected loadConfig path: ${path}`);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(mockLoadConfig).toHaveBeenCalledWith("/tmp/global-config.yaml");
|
||||
expect(mockLoadConfig).not.toHaveBeenCalledWith("/repo/agent-orchestrator.yaml");
|
||||
expect(activeWorkers.has("app")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves workers across a global→fallback transition (multi-tick)", async () => {
|
||||
// Tick 1: global exists with {alpha, beta, gamma} — supervisor attaches
|
||||
// all three. Tick 2: global has been deleted; the local fallback config
|
||||
// (passed-in configPath) lists only {alpha}. Without the source-aware
|
||||
// detach skip, the second tick would kill beta and gamma even though
|
||||
// they're still running real sessions.
|
||||
sessionsByProject.set("alpha", [makeSession("alpha")]);
|
||||
sessionsByProject.set("beta", [makeSession("beta")]);
|
||||
sessionsByProject.set("gamma", [makeSession("gamma")]);
|
||||
|
||||
// Tick 1: global is the source.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") return makeConfig(["alpha", "beta", "gamma"]);
|
||||
throw new Error(`unexpected path on tick 1: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
expect(activeWorkers.has("alpha")).toBe(true);
|
||||
expect(activeWorkers.has("beta")).toBe(true);
|
||||
expect(activeWorkers.has("gamma")).toBe(true);
|
||||
|
||||
// Tick 2: global deleted, fallback has a narrower view.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
if (path === "/repo/agent-orchestrator.yaml") return makeConfig(["alpha"]);
|
||||
throw new Error(`unexpected path on tick 2: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
// All three workers must survive — fallback isn't authoritative for removal.
|
||||
expect(activeWorkers.has("alpha")).toBe(true);
|
||||
expect(activeWorkers.has("beta")).toBe(true);
|
||||
expect(activeWorkers.has("gamma")).toBe(true);
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("beta");
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("gamma");
|
||||
});
|
||||
|
||||
it("does detach when global is restored after a fallback period (symmetric flip)", async () => {
|
||||
// Documents intentional current behavior: when source flips back to
|
||||
// "global", the detach pass treats the global config as authoritative,
|
||||
// so projects not listed there ARE detached — including any that were
|
||||
// attached during a prior fallback window. The reviewer's guidance was
|
||||
// scoped to the fallback direction; protecting the symmetric flip would
|
||||
// require per-worker source tracking and is out of scope here.
|
||||
sessionsByProject.set("local-only", [makeSession("local-only")]);
|
||||
sessionsByProject.set("from-global", [makeSession("from-global")]);
|
||||
|
||||
// Tick 1: no global, fallback attaches local-only.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
if (path === "/repo/agent-orchestrator.yaml") return makeConfig(["local-only"]);
|
||||
throw new Error(`unexpected path on tick 1: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
expect(activeWorkers.has("local-only")).toBe(true);
|
||||
|
||||
// Tick 2: global appears (e.g. another `ao start <url>` wrote it),
|
||||
// listing only "from-global". Source = "global" → detach pass runs.
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") return makeConfig(["from-global"]);
|
||||
throw new Error(`unexpected path on tick 2: ${path}`);
|
||||
});
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(activeWorkers.has("from-global")).toBe(true);
|
||||
expect(activeWorkers.has("local-only")).toBe(false);
|
||||
expect(mockRemoveProjectFromRunning).toHaveBeenCalledWith("local-only");
|
||||
});
|
||||
|
||||
it("does not detach unrelated active workers when operating from local fallback", async () => {
|
||||
// Simulates: daemon already supervising "other-project" (registered via
|
||||
// a prior reconcile against a global config that has since been deleted).
|
||||
// The current reconcile sees only "cwd-project" in the local fallback —
|
||||
// it must NOT treat "other-project" as removed.
|
||||
activeWorkers.add("other-project");
|
||||
sessionsByProject.set("cwd-project", [makeSession("cwd-project")]);
|
||||
mockLoadConfig.mockImplementation((path?: string) => {
|
||||
if (path === "/tmp/global-config.yaml") {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
}
|
||||
return makeConfig(["cwd-project"]);
|
||||
});
|
||||
|
||||
await reconcileProjectSupervisor({ configPath: "/repo/agent-orchestrator.yaml" });
|
||||
|
||||
expect(activeWorkers.has("other-project")).toBe(true);
|
||||
expect(mockRemoveProjectFromRunning).not.toHaveBeenCalledWith("other-project");
|
||||
// Attach pass still runs for the configured cwd project.
|
||||
expect(activeWorkers.has("cwd-project")).toBe(true);
|
||||
});
|
||||
|
||||
it("rethrows ENOENT from a nested file referenced by the global config", async () => {
|
||||
mockLoadConfig.mockImplementation(() => {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/some-referenced-file.yaml",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(reconcileProjectSupervisor()).rejects.toThrow("ENOENT");
|
||||
expect(mockLoadConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rethrows non-missing-config errors from the global config load", async () => {
|
||||
mockLoadConfig.mockImplementation(() => {
|
||||
throw new Error("invalid yaml");
|
||||
});
|
||||
|
||||
await expect(reconcileProjectSupervisor()).rejects.toThrow("invalid yaml");
|
||||
expect(mockLoadConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exits cleanly when neither global nor local config exists", async () => {
|
||||
const { ConfigNotFoundError } = await import("@aoagents/ao-core");
|
||||
mockLoadConfig
|
||||
.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error("ENOENT"), {
|
||||
code: "ENOENT",
|
||||
path: "/tmp/global-config.yaml",
|
||||
});
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new ConfigNotFoundError();
|
||||
});
|
||||
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_000 });
|
||||
|
||||
expect(handle).toEqual({
|
||||
stop: expect.any(Function),
|
||||
reconcileNow: expect.any(Function),
|
||||
});
|
||||
expect(mockEnsureLifecycleWorker).not.toHaveBeenCalled();
|
||||
handle.stop();
|
||||
});
|
||||
|
||||
it("forwards the supervisor interval to lifecycle workers it starts", async () => {
|
||||
sessionsByProject.set("app", [makeSession("app")]);
|
||||
|
||||
const handle = await startProjectSupervisor(1_234);
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_234 });
|
||||
|
||||
expect(mockEnsureLifecycleWorker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ configPath: "/tmp/global-config.yaml" }),
|
||||
|
|
@ -280,7 +506,7 @@ describe("project-supervisor", () => {
|
|||
});
|
||||
|
||||
it("reconcileNow waits for a queued reconcile when one is already running", async () => {
|
||||
const handle = await startProjectSupervisor(1_000);
|
||||
const handle = await startProjectSupervisor({ intervalMs: 1_000 });
|
||||
let firstRelease: (() => void) | undefined;
|
||||
let secondRelease: (() => void) | undefined;
|
||||
let listCalls = 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Tests for resolve-project.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Covers MUST emits:
|
||||
* - cli.project_resolve_failed (clone failure inside fromUrl)
|
||||
* - cli.config_recovery_failed (registerFlatConfig returns null)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as AoCore from "@aoagents/ao-core";
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.fn(),
|
||||
// resolveCloneTarget points to a tmp dir; isRepoAlreadyCloned forces the
|
||||
// clone path so cloneRepo is invoked (and can throw).
|
||||
resolveCloneTarget: () => "/tmp/__ao_test_clone_target__",
|
||||
isRepoAlreadyCloned: () => false,
|
||||
loadConfig: () => ({
|
||||
configPath: "/tmp/__ao_test_global_config__",
|
||||
projects: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/startup-preflight.js", () => ({
|
||||
ensureGit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
findFreePort: vi.fn().mockResolvedValue(3000),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
git: vi.fn().mockResolvedValue({ stdout: "" }),
|
||||
}));
|
||||
|
||||
import { resolveOrCreateProject } from "../../src/lib/resolve-project.js";
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(AoCore.recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
describe("resolve-project — activity events", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AoCore.recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
it("emits cli.project_resolve_failed when cloneRepo throws (URL into running daemon)", async () => {
|
||||
const cloneRepo = vi.fn(
|
||||
async (_parsed: AoCore.ParsedRepoUrl, _target: string, _cwd: string) => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveOrCreateProject(
|
||||
"https://github.com/foo/bar",
|
||||
{
|
||||
addProjectToConfig: vi.fn(),
|
||||
autoCreateConfig: vi.fn(),
|
||||
resolveProject: vi.fn(),
|
||||
resolveProjectByRepo: vi.fn(),
|
||||
registerFlatConfig: vi.fn().mockResolvedValue(null),
|
||||
cloneRepo,
|
||||
},
|
||||
// targetGlobalRegistry: true → exercises fromUrlIntoGlobal
|
||||
{ targetGlobalRegistry: true },
|
||||
),
|
||||
).rejects.toThrow(/Failed to clone/);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.project_resolve_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
ownerRepo: "foo/bar",
|
||||
errorMessage: "network down",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.config_recovery_failed when registerFlatConfig returns null", async () => {
|
||||
// Trigger fromCwdOrId via undefined arg; if loadConfig() throws something
|
||||
// other than ConfigNotFoundError, the recovery path runs and asks
|
||||
// registerFlatConfig to fix it.
|
||||
//
|
||||
// Here we force the recovery path to fail by stubbing the deps so that:
|
||||
// 1. autoCreateConfig is not invoked (fromCwdOrId only calls it when
|
||||
// loadConfig throws ConfigNotFoundError — we trigger a different
|
||||
// error so the registerFlatConfig branch runs).
|
||||
// 2. registerFlatConfig returns null (recovery fails).
|
||||
//
|
||||
// We can't easily make the real loadConfig() throw a non-ConfigNotFoundError
|
||||
// synchronously, so instead we patch findConfigFile via the mocked module
|
||||
// surface. The simplest, robust approach: simulate the public call shape
|
||||
// and assert that whenever `registerFlatConfig` returns null, the event
|
||||
// fires at the call site. To do that we drive the function with a
|
||||
// controlled cwd that lacks a parseable config but has a config file
|
||||
// present — replicated by stubbing findConfigFile in @aoagents/ao-core.
|
||||
|
||||
// Reach into the same module mock by re-mocking findConfigFile + loadConfig.
|
||||
vi.doMock("@aoagents/ao-core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AoCore>();
|
||||
return {
|
||||
...actual,
|
||||
recordActivityEvent: vi.mocked(AoCore.recordActivityEvent),
|
||||
// findConfigFile returns a path so the recovery branch runs.
|
||||
findConfigFile: () => "/tmp/__ao_test_flat_config__",
|
||||
// loadConfig throws a generic Error (not ConfigNotFoundError) so the
|
||||
// catch block falls into the registerFlatConfig recovery branch.
|
||||
loadConfig: () => {
|
||||
throw new Error("malformed config");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveOrCreateProject: resolveOrCreateProjectReloaded } =
|
||||
await import("../../src/lib/resolve-project.js");
|
||||
// Re-grab the mock so cleared calls inside the doMock factory don't get lost.
|
||||
const { recordActivityEvent: reloadedRecord } = await import("@aoagents/ao-core");
|
||||
vi.mocked(reloadedRecord).mockClear();
|
||||
|
||||
await expect(
|
||||
resolveOrCreateProjectReloaded(
|
||||
undefined,
|
||||
{
|
||||
addProjectToConfig: vi.fn(),
|
||||
autoCreateConfig: vi.fn(),
|
||||
resolveProject: vi.fn(),
|
||||
resolveProjectByRepo: vi.fn(),
|
||||
registerFlatConfig: vi.fn().mockResolvedValue(null),
|
||||
cloneRepo: vi.fn(),
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(/malformed config/);
|
||||
|
||||
const events = vi.mocked(reloadedRecord).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.config_recovery_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
configPath: "/tmp/__ao_test_flat_config__",
|
||||
errorMessage: "malformed config",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doUnmock("@aoagents/ao-core");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Tests for shutdown.ts activity-event instrumentation (issue #1654).
|
||||
*
|
||||
* Each test mounts handlers via `installShutdownHandlers`, fires a signal,
|
||||
* and asserts the expected `cli.*` activity events are emitted. Real
|
||||
* `process.exit` is stubbed so the test process is not terminated by the
|
||||
* shutdown handler.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
const {
|
||||
mockListSessions,
|
||||
mockKillSession,
|
||||
mockGetSessionManager,
|
||||
mockUnregister,
|
||||
mockWriteLastStop,
|
||||
mockStopBunTmpJanitor,
|
||||
mockStopProjectSupervisor,
|
||||
mockStopAllLifecycleWorkers,
|
||||
mockLoadConfig,
|
||||
mockIsTerminalSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListSessions: vi.fn(),
|
||||
mockKillSession: vi.fn(),
|
||||
mockGetSessionManager: vi.fn(),
|
||||
mockUnregister: vi.fn(),
|
||||
mockWriteLastStop: vi.fn(),
|
||||
mockStopBunTmpJanitor: vi.fn(),
|
||||
mockStopProjectSupervisor: vi.fn(),
|
||||
mockStopAllLifecycleWorkers: vi.fn(),
|
||||
mockLoadConfig: vi.fn(),
|
||||
mockIsTerminalSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importOriginal<typeof import("@aoagents/ao-core")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
|
||||
isTerminalSession: (...args: unknown[]) => mockIsTerminalSession(...args),
|
||||
recordActivityEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: (...args: unknown[]) => mockGetSessionManager(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/lifecycle-service.js", () => ({
|
||||
stopAllLifecycleWorkers: (...args: unknown[]) => mockStopAllLifecycleWorkers(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/project-supervisor.js", () => ({
|
||||
stopProjectSupervisor: (...args: unknown[]) => mockStopProjectSupervisor(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
unregister: (...args: unknown[]) => mockUnregister(...args),
|
||||
writeLastStop: (...args: unknown[]) => mockWriteLastStop(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/bun-tmp-janitor.js", () => ({
|
||||
stopBunTmpJanitor: (...args: unknown[]) => mockStopBunTmpJanitor(...args),
|
||||
}));
|
||||
|
||||
const recordedEvents = (): Array<Record<string, unknown>> =>
|
||||
vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record<string, unknown>);
|
||||
|
||||
const flushAsync = async (): Promise<void> => {
|
||||
// The shutdown handler launches an async IIFE; allow it to settle.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
};
|
||||
|
||||
describe("shutdown handlers — activity events", () => {
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let originalListenersSigint: NodeJS.SignalsListener[];
|
||||
let originalListenersSigterm: NodeJS.SignalsListener[];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
mockListSessions.mockReset();
|
||||
mockKillSession.mockReset();
|
||||
mockGetSessionManager.mockReset();
|
||||
mockUnregister.mockReset();
|
||||
mockWriteLastStop.mockReset();
|
||||
mockStopBunTmpJanitor.mockReset();
|
||||
mockStopProjectSupervisor.mockReset();
|
||||
mockStopAllLifecycleWorkers.mockReset();
|
||||
mockLoadConfig.mockReset();
|
||||
mockIsTerminalSession.mockReset();
|
||||
|
||||
mockLoadConfig.mockReturnValue({ projects: {} });
|
||||
mockIsTerminalSession.mockReturnValue(false);
|
||||
mockGetSessionManager.mockResolvedValue({
|
||||
list: mockListSessions,
|
||||
kill: mockKillSession,
|
||||
});
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
mockKillSession.mockResolvedValue({ cleaned: true });
|
||||
mockUnregister.mockResolvedValue(undefined);
|
||||
mockWriteLastStop.mockResolvedValue(undefined);
|
||||
mockStopBunTmpJanitor.mockResolvedValue(undefined);
|
||||
|
||||
// Snapshot existing signal listeners so we can restore them after each
|
||||
// test and avoid leaking handlers across tests in the same process.
|
||||
originalListenersSigint = process.listeners("SIGINT") as NodeJS.SignalsListener[];
|
||||
originalListenersSigterm = process.listeners("SIGTERM") as NodeJS.SignalsListener[];
|
||||
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
||||
// Throw a sentinel to short-circuit the async IIFE without leaving
|
||||
// the test process in an unknown state.
|
||||
return undefined as never;
|
||||
}) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitSpy.mockRestore();
|
||||
|
||||
// Restore signal listeners
|
||||
process.removeAllListeners("SIGINT");
|
||||
process.removeAllListeners("SIGTERM");
|
||||
for (const l of originalListenersSigint) process.on("SIGINT", l);
|
||||
for (const l of originalListenersSigterm) process.on("SIGTERM", l);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_signal when SIGINT is received", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_signal",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
data: expect.objectContaining({ signal: "SIGINT", exitCode: 130 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_completed after clean shutdown", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_completed",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_failed when shutdown body throws before completion", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
mockGetSessionManager.mockRejectedValue(new Error("getSessionManager boom"));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({ errorMessage: "getSessionManager boom" }),
|
||||
}),
|
||||
);
|
||||
// Failure path should NOT emit shutdown_completed
|
||||
const completedEvents = events.filter((e) => e.kind === "cli.shutdown_completed");
|
||||
expect(completedEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still unregisters running state when writing last-stop state fails", async () => {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
mockListSessions.mockResolvedValue([
|
||||
{
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
status: "working",
|
||||
},
|
||||
]);
|
||||
mockWriteLastStop.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGTERM", "SIGTERM");
|
||||
await flushAsync();
|
||||
|
||||
expect(mockWriteLastStop).toHaveBeenCalled();
|
||||
expect(mockUnregister).toHaveBeenCalled();
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.last_stop_write_failed",
|
||||
source: "cli",
|
||||
level: "error",
|
||||
data: expect.objectContaining({
|
||||
targetSessionCount: 1,
|
||||
otherProjectCount: 0,
|
||||
totalKilled: 1,
|
||||
errorMessage: "disk full",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_completed",
|
||||
source: "cli",
|
||||
projectId: "p1",
|
||||
}),
|
||||
);
|
||||
expect(events.filter((e) => e.kind === "cli.shutdown_failed")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits cli.shutdown_force_exit when the 10s timer fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { installShutdownHandlers } = await import("../../src/lib/shutdown.js");
|
||||
// Hang the async cleanup so the force-exit timer wins.
|
||||
mockGetSessionManager.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
installShutdownHandlers({ configPath: "/tmp/cfg.yaml", projectId: "p1" });
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
// Advance past the 10s timeout
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const events = recordedEvents();
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "cli.shutdown_force_exit",
|
||||
source: "cli",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({ timeoutMs: 10_000, exitCode: 130 }),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
betterSqlite3BindingCandidates,
|
||||
betterSqlite3RebuildCommand,
|
||||
hasBetterSqlite3Binding,
|
||||
} from "../../../ao/bin/postinstall.js";
|
||||
|
||||
describe("ao postinstall better-sqlite3 native binding detection", () => {
|
||||
const env = {
|
||||
platform: "darwin",
|
||||
arch: "arm64",
|
||||
modules: "141",
|
||||
nodeVersion: "25.9.0",
|
||||
};
|
||||
|
||||
it("checks the current Node ABI binding path", () => {
|
||||
const packageDir = "virtual-better-sqlite3";
|
||||
const candidates = betterSqlite3BindingCandidates(packageDir, env);
|
||||
|
||||
expect(candidates.some((candidate) => candidate.includes("node-v141-darwin-arm64"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the binding present when a mocked candidate exists", () => {
|
||||
const packageDir = "virtual-better-sqlite3";
|
||||
const candidates = betterSqlite3BindingCandidates(packageDir, env);
|
||||
const currentAbiBinding = candidates.find((candidate) =>
|
||||
candidate.includes("node-v141-darwin-arm64"),
|
||||
);
|
||||
if (!currentAbiBinding) {
|
||||
throw new Error("expected current ABI binding candidate");
|
||||
}
|
||||
|
||||
const existingFiles = new Set([currentAbiBinding]);
|
||||
|
||||
expect(
|
||||
hasBetterSqlite3Binding(packageDir, {
|
||||
...env,
|
||||
existsSync: (candidate: string) => existingFiles.has(candidate),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the binding missing when no mocked candidate exists", () => {
|
||||
expect(
|
||||
hasBetterSqlite3Binding("virtual-better-sqlite3", {
|
||||
...env,
|
||||
existsSync: () => false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses pnpm to rebuild inside the resolved better-sqlite3 package", () => {
|
||||
expect(
|
||||
betterSqlite3RebuildCommand("virtual-better-sqlite3", {
|
||||
npm_config_user_agent: "pnpm/9.15.4 npm/? node/v25.9.0 darwin arm64",
|
||||
}),
|
||||
).toEqual({
|
||||
command: "pnpm",
|
||||
args: ["--dir", "virtual-better-sqlite3", "rebuild"],
|
||||
display: "pnpm --dir virtual-better-sqlite3 rebuild",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aoagents/ao-core": "workspace:*",
|
||||
"@aoagents/ao-notifier-macos": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-aider": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-claude-code": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-codex": "workspace:*",
|
||||
|
|
@ -41,6 +42,7 @@
|
|||
"@aoagents/ao-plugin-agent-kimicode": "workspace:*",
|
||||
"@aoagents/ao-plugin-agent-opencode": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-composio": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-dashboard": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-desktop": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-discord": "workspace:*",
|
||||
"@aoagents/ao-plugin-notifier-openclaw": "workspace:*",
|
||||
|
|
@ -57,14 +59,16 @@
|
|||
"@aoagents/ao-plugin-workspace-worktree": "workspace:*",
|
||||
"@aoagents/ao-web": "workspace:*",
|
||||
"@clack/prompts": "^0.9.1",
|
||||
"@composio/core": "^0.9.0",
|
||||
"chalk": "^5.4.0",
|
||||
"commander": "^13.0.0",
|
||||
"ora": "^8.1.0",
|
||||
"yaml": "^2.7.0"
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import {
|
|||
getObservabilityBaseDir,
|
||||
loadConfig,
|
||||
resolveNotifierTarget,
|
||||
type Notifier,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type PluginSlot,
|
||||
} from "@aoagents/ao-core";
|
||||
import { runNotifyTest } from "../lib/notify-test.js";
|
||||
import { runRepoScript } from "../lib/script-runner.js";
|
||||
import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js";
|
||||
import { importPluginModuleFromSource } from "../lib/plugin-store.js";
|
||||
|
|
@ -338,54 +338,34 @@ async function sendTestNotifications(
|
|||
registry: PluginRegistry,
|
||||
fail: (msg: string) => void,
|
||||
): Promise<void> {
|
||||
const activeNotifierNames = config.defaults?.notifiers ?? [];
|
||||
const targets = new Map<string, ReturnType<typeof resolveNotifierTarget>>();
|
||||
const result = await runNotifyTest(config, registry, {
|
||||
templateName: "basic",
|
||||
all: true,
|
||||
message: "Test notification from ao doctor --test-notify",
|
||||
sessionId: "doctor-test",
|
||||
projectId: "doctor",
|
||||
data: { source: "ao-doctor" },
|
||||
});
|
||||
|
||||
for (const name of Object.keys(config.notifiers ?? {})) {
|
||||
targets.set(name, resolveNotifierTarget(config, name));
|
||||
}
|
||||
|
||||
for (const name of activeNotifierNames) {
|
||||
const target = resolveNotifierTarget(config, name);
|
||||
if (!targets.has(target.reference)) {
|
||||
targets.set(target.reference, target);
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.size === 0) {
|
||||
if (result.targets.length === 0) {
|
||||
warn("No notifiers to test. Fix: configure notifiers in your agent-orchestrator.yaml");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nSending test notification to ${targets.size} notifier(s)...\n`);
|
||||
console.log(`\nSending test notification to ${result.targets.length} notifier(s)...\n`);
|
||||
|
||||
for (const target of targets.values()) {
|
||||
const notifier =
|
||||
registry.get<Notifier>("notifier", target.reference) ??
|
||||
registry.get<Notifier>("notifier", target.pluginName);
|
||||
if (!notifier) {
|
||||
warn(`${target.reference}: plugin "${target.pluginName}" not loaded (may not be installed)`);
|
||||
continue;
|
||||
for (const delivery of result.deliveries) {
|
||||
if (delivery.status === "sent") {
|
||||
pass(`${delivery.reference}: test notification sent`);
|
||||
} else if (delivery.status === "unresolved") {
|
||||
warn(`${delivery.reference}: plugin "${delivery.pluginName}" not loaded (may not be installed)`);
|
||||
} else if (delivery.error) {
|
||||
fail(delivery.error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const testEvent = {
|
||||
id: `doctor-test-${Date.now()}`,
|
||||
type: "summary.all_complete" as const,
|
||||
priority: "info" as const,
|
||||
sessionId: "doctor-test",
|
||||
projectId: "doctor",
|
||||
timestamp: new Date(),
|
||||
message: "Test notification from ao doctor --test-notify",
|
||||
data: { source: "ao-doctor" },
|
||||
};
|
||||
|
||||
await notifier.notify(testEvent);
|
||||
pass(`${target.reference}: test notification sent`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
fail(`${target.reference}: ${message}`);
|
||||
}
|
||||
for (const warning of result.warnings) {
|
||||
warn(warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type ActivityEvent,
|
||||
type ActivityEventLevel,
|
||||
type ActivityEventKind,
|
||||
type ActivityEventSource,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
interface JsonEnvelope {
|
||||
|
|
@ -80,7 +81,12 @@ export function registerEvents(program: Command): void {
|
|||
.description("List recent activity events")
|
||||
.option("-p, --project <id>", "Filter by project ID")
|
||||
.option("-s, --session <id>", "Filter by session ID")
|
||||
.option("-t, --type <kind>", "Filter by event kind (e.g. session.spawned, lifecycle.transition)")
|
||||
.option(
|
||||
"-t, --type <kind>",
|
||||
"Filter by event kind (e.g. session.spawned, lifecycle.transition)",
|
||||
)
|
||||
.option("--kind <kind>", "Alias for --type")
|
||||
.option("--source <source>", "Filter by event source (e.g. lifecycle, recovery, api)")
|
||||
.option("--log-level <level>", "Filter by log level (debug, info, warn, error)")
|
||||
.option("--since <duration>", "Show events from last N minutes/hours/days (e.g. 30m, 2h, 1d)")
|
||||
.option("-n, --limit <n>", "Max results", "50")
|
||||
|
|
@ -91,15 +97,21 @@ export function registerEvents(program: Command): void {
|
|||
if (sinceRaw) {
|
||||
since = parseSinceDuration(sinceRaw);
|
||||
if (!since) {
|
||||
console.error(chalk.yellow(`Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`));
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: unrecognised --since format "${sinceRaw}" (use e.g. 30m, 2h, 1d). No time filter applied.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const limit = parseInt(opts["limit"] ?? "50", 10);
|
||||
const kind = opts["type"] ?? opts["kind"];
|
||||
|
||||
const results = queryActivityEvents({
|
||||
projectId: opts["project"],
|
||||
sessionId: opts["session"],
|
||||
kind: opts["type"] as ActivityEventKind,
|
||||
kind: kind as ActivityEventKind,
|
||||
source: opts["source"] as ActivityEventSource,
|
||||
level: opts["logLevel"] as ActivityEventLevel,
|
||||
since,
|
||||
limit,
|
||||
|
|
@ -111,7 +123,8 @@ export function registerEvents(program: Command): void {
|
|||
query: {
|
||||
projectId: opts["project"] ?? null,
|
||||
sessionId: opts["session"] ?? null,
|
||||
kind: opts["type"] ?? null,
|
||||
kind: kind ?? null,
|
||||
source: opts["source"] ?? null,
|
||||
level: opts["logLevel"] ?? null,
|
||||
since: sinceRaw ?? null,
|
||||
limit,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import { migrateStorage, rollbackStorage } from "@aoagents/ao-core";
|
||||
import { migrateStorage, recordActivityEvent, rollbackStorage } from "@aoagents/ao-core";
|
||||
|
||||
export function registerMigrateStorage(program: Command): void {
|
||||
program
|
||||
|
|
@ -13,12 +13,31 @@ export function registerMigrateStorage(program: Command): void {
|
|||
.option("--rollback", "Reverse a previous migration (restores .migrated directories)")
|
||||
.action(
|
||||
async (opts: { dryRun?: boolean; force?: boolean; rollback?: boolean }) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_invoked",
|
||||
level: "info",
|
||||
summary: `storage ${opts.rollback ? "rollback" : "migration"} invoked`,
|
||||
data: {
|
||||
rollback: opts.rollback === true,
|
||||
dryRun: opts.dryRun === true,
|
||||
force: opts.force === true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (opts.rollback) {
|
||||
await rollbackStorage({
|
||||
dryRun: opts.dryRun,
|
||||
log: (msg) => console.log(msg),
|
||||
});
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_completed",
|
||||
level: "info",
|
||||
summary: `storage rollback completed`,
|
||||
data: { rollback: true, dryRun: opts.dryRun === true },
|
||||
});
|
||||
} else {
|
||||
const result = await migrateStorage({
|
||||
dryRun: opts.dryRun,
|
||||
|
|
@ -31,8 +50,30 @@ export function registerMigrateStorage(program: Command): void {
|
|||
} else {
|
||||
console.log(chalk.green("\nMigration complete."));
|
||||
}
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_completed",
|
||||
level: "info",
|
||||
summary: `storage migration completed (${result.projects} project(s))`,
|
||||
data: {
|
||||
rollback: false,
|
||||
dryRun: opts.dryRun === true,
|
||||
force: opts.force === true,
|
||||
projects: result.projects,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.migration_failed",
|
||||
level: "error",
|
||||
summary: `storage migration failed`,
|
||||
data: {
|
||||
rollback: opts.rollback === true,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import type { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
createPluginRegistry,
|
||||
findConfigFile,
|
||||
loadConfig,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
import { importPluginModuleFromSource } from "../lib/plugin-store.js";
|
||||
import {
|
||||
addSinkNotifierConfig,
|
||||
parseNotifyDataJson,
|
||||
parseNotifyRefs,
|
||||
parseSinkPort,
|
||||
runNotifyTest,
|
||||
startNotifySink,
|
||||
type NotifySinkServer,
|
||||
type NotifyTestRequest,
|
||||
type NotifyTestResult,
|
||||
} from "../lib/notify-test.js";
|
||||
|
||||
interface NotifyTestCommandOptions {
|
||||
template?: string;
|
||||
to?: string;
|
||||
all?: boolean;
|
||||
route?: string;
|
||||
actions?: boolean;
|
||||
message?: string;
|
||||
session?: string;
|
||||
project?: string;
|
||||
priority?: string;
|
||||
type?: string;
|
||||
data?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
sink?: true | string;
|
||||
}
|
||||
|
||||
async function loadNotifierRegistry(config: OrchestratorConfig): Promise<PluginRegistry> {
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config, importPluginModuleFromSource);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function printJson(result: NotifyTestResult, sinkRequests?: unknown[]): void {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...result,
|
||||
sinkRequests,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function printHumanResult(result: NotifyTestResult, sinkRequests?: unknown[]): void {
|
||||
console.log(
|
||||
`${result.dryRun ? "Dry run" : "Sent"} ${result.templateName} notification (${result.event.type}, ${result.event.priority})`,
|
||||
);
|
||||
console.log(`Event id: ${result.event.id}`);
|
||||
console.log(`Session: ${result.event.projectId}/${result.event.sessionId}`);
|
||||
|
||||
if (result.targets.length > 0) {
|
||||
console.log("");
|
||||
console.log("Targets:");
|
||||
for (const target of result.targets) {
|
||||
console.log(` ${target.reference} -> ${target.pluginName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.deliveries.length > 0) {
|
||||
console.log("");
|
||||
console.log("Delivery:");
|
||||
for (const delivery of result.deliveries) {
|
||||
if (delivery.status === "sent") {
|
||||
console.log(` ${chalk.green("PASS")} ${delivery.reference}: ${delivery.method}`);
|
||||
} else if (delivery.status === "dry_run") {
|
||||
console.log(` ${chalk.cyan("DRY")} ${delivery.reference}: ${delivery.method}`);
|
||||
} else {
|
||||
console.log(` ${chalk.red("FAIL")} ${delivery.reference}: ${delivery.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const warning of result.warnings) {
|
||||
console.log(`${chalk.yellow("WARN")} ${warning}`);
|
||||
}
|
||||
|
||||
for (const error of result.errors) {
|
||||
console.error(`${chalk.red("FAIL")} ${error}`);
|
||||
}
|
||||
|
||||
if (sinkRequests && sinkRequests.length > 0) {
|
||||
console.log("");
|
||||
console.log("Sink received:");
|
||||
console.log(JSON.stringify(sinkRequests[0], null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function commandRequest(opts: NotifyTestCommandOptions, forceSinkTarget: boolean): NotifyTestRequest {
|
||||
const request: NotifyTestRequest = {
|
||||
templateName: opts.template,
|
||||
to: forceSinkTarget ? ["sink"] : parseNotifyRefs(opts.to),
|
||||
all: opts.all,
|
||||
route: opts.route,
|
||||
actions: opts.actions,
|
||||
message: opts.message,
|
||||
sessionId: opts.session,
|
||||
projectId: opts.project,
|
||||
priority: opts.priority,
|
||||
type: opts.type,
|
||||
data: parseNotifyDataJson(opts.data),
|
||||
dryRun: opts.dryRun,
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export function registerNotify(program: Command): void {
|
||||
const notify = program.command("notify").description("Work with configured notification targets");
|
||||
|
||||
notify
|
||||
.command("test")
|
||||
.description("Send a manual demo notification without spawning sessions")
|
||||
.option("--template <name>", "Demo template to send", "basic")
|
||||
.option("--to <refs>", "Comma-separated notifier refs to target")
|
||||
.option("--all", "Send to all configured, default, and routed notifier refs")
|
||||
.option("--route <urgent|action|warning|info>", "Send through a priority route")
|
||||
.option("--actions", "Send demo actions when supported")
|
||||
.option("--message <text>", "Override the notification message")
|
||||
.option("--session <id>", "Override the demo session id")
|
||||
.option("--project <id>", "Override the demo project id")
|
||||
.option("--priority <level>", "Override the event priority")
|
||||
.option("--type <eventType>", "Override the event type")
|
||||
.option("--data <json>", "Merge JSON object into the event data")
|
||||
.option("--dry-run", "Resolve and print the notification without sending it")
|
||||
.option("--json", "Print structured JSON output")
|
||||
.option("--sink [port]", "Add an in-memory local webhook target named sink")
|
||||
.action(async (opts: NotifyTestCommandOptions) => {
|
||||
let sink: NotifySinkServer | undefined;
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const sinkPort = parseSinkPort(opts.sink);
|
||||
const forceSinkTarget = sinkPort !== undefined && !opts.to && !opts.all && !opts.route;
|
||||
const request = commandRequest(opts, forceSinkTarget);
|
||||
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No config file found. Cannot test notifiers without agent-orchestrator.yaml");
|
||||
}
|
||||
|
||||
let config: OrchestratorConfig = loadConfig(configPath);
|
||||
if (sinkPort !== undefined) {
|
||||
const sinkUrl = opts.dryRun ? `http://127.0.0.1:${sinkPort}` : undefined;
|
||||
if (!opts.dryRun) {
|
||||
sink = await startNotifySink(sinkPort);
|
||||
}
|
||||
config = addSinkNotifierConfig(config, sink?.url ?? sinkUrl ?? "http://127.0.0.1:0");
|
||||
}
|
||||
|
||||
const registry = await loadNotifierRegistry(config);
|
||||
const result = await runNotifyTest(config, registry, request);
|
||||
const sinkRequest = sink ? await sink.waitForRequest(1000) : null;
|
||||
const sinkRequests = sinkRequest ? [sinkRequest] : sink?.requests;
|
||||
|
||||
if (opts.json) {
|
||||
printJson(result, sinkRequests);
|
||||
} else {
|
||||
printHumanResult(result, sinkRequests);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
exitCode = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, errors: [message] }, null, 2));
|
||||
} else {
|
||||
console.error(`${chalk.red("FAIL")} ${message}`);
|
||||
}
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
if (sink) {
|
||||
await sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -509,38 +509,51 @@ export function registerPlugin(program: Command): void {
|
|||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)",
|
||||
"Remote/manual OpenClaw token fallback (passed to setup when installing notifier-openclaw)",
|
||||
)
|
||||
.action(async (reference: string, opts: { url?: string; token?: string }) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath);
|
||||
const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier);
|
||||
const previousPlugins = config.plugins ?? [];
|
||||
const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor);
|
||||
writePluginsConfig(configPath, nextPlugins);
|
||||
|
||||
console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath)));
|
||||
|
||||
if (setupAction === "openclaw-setup") {
|
||||
// Always run setup — interactive in TTY, auto-detect in non-TTY.
|
||||
// Non-interactive setup will auto-detect OpenClaw on localhost if
|
||||
// no --url is given and the gateway is reachable.
|
||||
try {
|
||||
await runSetupAction({ url: opts.url, token: opts.token });
|
||||
} catch (err) {
|
||||
// Rollback: restore previous plugin list so a failed setup
|
||||
// doesn't leave a half-configured notifier enabled in config.
|
||||
writePluginsConfig(configPath, previousPlugins);
|
||||
console.log(chalk.dim("Rolled back plugin config after setup failure."));
|
||||
throw err;
|
||||
.option(
|
||||
"--openclaw-config-path <path>",
|
||||
"OpenClaw config path that contains hooks.token (passed to setup when installing notifier-openclaw)",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
reference: string,
|
||||
opts: { url?: string; token?: string; openclawConfigPath?: string },
|
||||
) => {
|
||||
const configPath = findConfigFile();
|
||||
if (!configPath) {
|
||||
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath);
|
||||
const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier);
|
||||
const previousPlugins = config.plugins ?? [];
|
||||
const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor);
|
||||
writePluginsConfig(configPath, nextPlugins);
|
||||
|
||||
console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath)));
|
||||
|
||||
if (setupAction === "openclaw-setup") {
|
||||
// Always run setup — interactive in TTY, auto-detect in non-TTY.
|
||||
// Non-interactive setup will auto-detect OpenClaw on localhost if
|
||||
// no --url is given and the gateway is reachable.
|
||||
try {
|
||||
await runSetupAction({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
openclawConfigPath: opts.openclawConfigPath,
|
||||
});
|
||||
} catch (err) {
|
||||
// Rollback: restore previous plugin list so a failed setup
|
||||
// doesn't leave a half-configured notifier enabled in config.
|
||||
writePluginsConfig(configPath, previousPlugins);
|
||||
console.log(chalk.dim("Rolled back plugin config after setup failure."));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
plugin
|
||||
.command("update")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getPortfolio,
|
||||
getPortfolioSessionCounts,
|
||||
isPortfolioEnabled,
|
||||
recordActivityEvent,
|
||||
registerProject,
|
||||
unregisterProject,
|
||||
loadPreferences,
|
||||
|
|
@ -94,18 +95,48 @@ export function registerProjectCommand(program: Command): void {
|
|||
const existingConfigPath = candidatePaths.find((candidate) => existsSync(candidate));
|
||||
|
||||
if (!existingConfigPath) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "warn",
|
||||
summary: `ao project add: no agent-orchestrator config found`,
|
||||
data: { resolvedPath, reason: "no_config_found" },
|
||||
});
|
||||
console.error(chalk.red(`No agent-orchestrator.yaml found at ${resolvedPath}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
loadConfig(existingConfigPath);
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "warn",
|
||||
summary: `ao project add: found old-format config requiring migration`,
|
||||
data: {
|
||||
resolvedPath,
|
||||
configPath: existingConfigPath,
|
||||
reason: "old_format",
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Found old-format config at ${existingConfigPath}. Run \`ao start\` in that project to migrate it before using \`ao project add\`.`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_register_failed",
|
||||
level: "error",
|
||||
summary: `ao project add: config load failed`,
|
||||
data: {
|
||||
resolvedPath,
|
||||
configPath: existingConfigPath,
|
||||
reason: "load_error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
`Found agent-orchestrator config at ${existingConfigPath}, but it could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,294 @@
|
|||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
createShellCodeReviewRunner,
|
||||
createCodeReviewStore,
|
||||
executeCodeReviewRun,
|
||||
loadConfig,
|
||||
sendCodeReviewFindingsToAgent,
|
||||
SessionNotFoundError,
|
||||
triggerCodeReviewForSession,
|
||||
type CodeReviewRunStatus,
|
||||
type CodeReviewRunSummary,
|
||||
} from "@aoagents/ao-core";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
|
||||
const RUN_STATUSES: ReadonlySet<CodeReviewRunStatus> = new Set([
|
||||
"queued",
|
||||
"preparing",
|
||||
"running",
|
||||
"needs_triage",
|
||||
"sent_to_agent",
|
||||
"waiting_update",
|
||||
"clean",
|
||||
"outdated",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
function parseRunStatus(value: string | undefined): CodeReviewRunStatus | undefined {
|
||||
if (!value) return undefined;
|
||||
if (RUN_STATUSES.has(value as CodeReviewRunStatus)) {
|
||||
return value as CodeReviewRunStatus;
|
||||
}
|
||||
throw new Error(`Unknown review status: ${value}`);
|
||||
}
|
||||
|
||||
function printRun(run: CodeReviewRunSummary): void {
|
||||
const findings =
|
||||
run.openFindingCount === 1 ? "1 open finding" : `${run.openFindingCount} open findings`;
|
||||
const parts = [
|
||||
chalk.green(run.reviewerSessionId),
|
||||
chalk.dim(run.id),
|
||||
run.status,
|
||||
chalk.cyan(run.linkedSessionId),
|
||||
findings,
|
||||
];
|
||||
|
||||
if (run.prNumber) {
|
||||
parts.push(chalk.blue(`PR #${run.prNumber}`));
|
||||
}
|
||||
|
||||
console.log(parts.join(" "));
|
||||
}
|
||||
|
||||
function printSendResult(run: CodeReviewRunSummary, sentFindingCount: number): void {
|
||||
const findings = sentFindingCount === 1 ? "1 finding" : `${sentFindingCount} findings`;
|
||||
console.log(chalk.green(`Sent ${findings} to ${chalk.cyan(run.linkedSessionId)}:`));
|
||||
printRun(run);
|
||||
}
|
||||
|
||||
function getRunProjectId(
|
||||
projectIds: string[],
|
||||
runId: string,
|
||||
): { projectId: string; run: CodeReviewRunSummary } | null {
|
||||
for (const projectId of projectIds) {
|
||||
const run = createCodeReviewStore(projectId)
|
||||
.listRunSummaries()
|
||||
.find((entry) => entry.id === runId || entry.reviewerSessionId === runId);
|
||||
if (run) return { projectId, run };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNextQueuedRun(
|
||||
projectIds: string[],
|
||||
): { projectId: string; run: CodeReviewRunSummary } | null {
|
||||
return (
|
||||
projectIds
|
||||
.flatMap((projectId) =>
|
||||
createCodeReviewStore(projectId)
|
||||
.listRunSummaries({ status: "queued" })
|
||||
.map((run) => ({ projectId, run })),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.run.createdAt.localeCompare(b.run.createdAt) ||
|
||||
a.projectId.localeCompare(b.projectId) ||
|
||||
a.run.id.localeCompare(b.run.id),
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function registerReview(program: Command): void {
|
||||
const review = program.command("review").description("Manage AO-local reviewer runs");
|
||||
|
||||
review
|
||||
.command("run")
|
||||
.description("Request a reviewer run for a worker session")
|
||||
.argument("<session>", "Worker session ID")
|
||||
.option("--summary <text>", "Summary to store on the review run")
|
||||
.option("--status <status>", "Initial run status (defaults to queued)")
|
||||
.option("--execute", "Execute the review run immediately")
|
||||
.option("--command <command>", "Shell command to execute as the reviewer")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (
|
||||
sessionId: string,
|
||||
opts: {
|
||||
summary?: string;
|
||||
status?: string;
|
||||
execute?: boolean;
|
||||
command?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const sessionManager = await getSessionManager(config);
|
||||
let run = await triggerCodeReviewForSession(
|
||||
{ config, sessionManager },
|
||||
{
|
||||
sessionId,
|
||||
requestedBy: "cli",
|
||||
status: parseRunStatus(opts.status),
|
||||
summary: opts.summary,
|
||||
},
|
||||
);
|
||||
|
||||
if (opts.execute || opts.command) {
|
||||
run = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}),
|
||||
},
|
||||
{ projectId: run.projectId, runId: run.id },
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ run }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(
|
||||
opts.execute || opts.command ? "Review run executed:" : "Review run requested:",
|
||||
),
|
||||
);
|
||||
printRun(run);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
console.error(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
review
|
||||
.command("execute")
|
||||
.description("Execute a queued AO-local reviewer run")
|
||||
.argument("[project]", "Project ID (searches all projects if omitted)")
|
||||
.option("--run <run>", "Review run ID or reviewer session ID")
|
||||
.option("--command <command>", "Shell command to execute as the reviewer")
|
||||
.option("--force", "Execute even if the run is not queued")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (
|
||||
projectId: string | undefined,
|
||||
opts: { run?: string; command?: string; force?: boolean; json?: boolean },
|
||||
) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (projectId && !config.projects[projectId]) {
|
||||
throw new Error(`Unknown project: ${projectId}`);
|
||||
}
|
||||
|
||||
const projectIds = projectId ? [projectId] : Object.keys(config.projects);
|
||||
const target = opts.run
|
||||
? getRunProjectId(projectIds, opts.run)
|
||||
: getNextQueuedRun(projectIds);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
opts.run ? `Review run not found: ${opts.run}` : "No queued review runs found.",
|
||||
);
|
||||
}
|
||||
|
||||
const sessionManager = await getSessionManager(config);
|
||||
const run = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
force: opts.force,
|
||||
...(opts.command ? { runReviewer: createShellCodeReviewRunner(opts.command) } : {}),
|
||||
},
|
||||
{ projectId: target.projectId, runId: target.run.id },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ run }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.green("Review run executed:"));
|
||||
printRun(run);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
console.error(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
review
|
||||
.command("send")
|
||||
.description("Send open AO-local review findings to the linked coding worker")
|
||||
.argument("<run>", "Review run ID or reviewer session ID")
|
||||
.option("-p, --project <project>", "Project ID (searches all projects if omitted)")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (runRef: string, opts: { project?: string; json?: boolean }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (opts.project && !config.projects[opts.project]) {
|
||||
throw new Error(`Unknown project: ${opts.project}`);
|
||||
}
|
||||
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
const target = getRunProjectId(projectIds, runRef);
|
||||
if (!target) {
|
||||
throw new Error(`Review run not found: ${runRef}`);
|
||||
}
|
||||
|
||||
const sessionManager = await getSessionManager(config);
|
||||
const result = await sendCodeReviewFindingsToAgent(
|
||||
{ config, sessionManager },
|
||||
{ projectId: target.projectId, runId: target.run.id },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printSendResult(result.run, result.sentFindingCount);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionNotFoundError) {
|
||||
console.error(chalk.red(error.message));
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
review
|
||||
.command("list")
|
||||
.description("List AO-local reviewer runs")
|
||||
.argument("[project]", "Project ID (lists all projects if omitted)")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (projectId: string | undefined, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
if (projectId && !config.projects[projectId]) {
|
||||
throw new Error(`Unknown project: ${projectId}`);
|
||||
}
|
||||
|
||||
const projectIds = projectId ? [projectId] : Object.keys(config.projects);
|
||||
const runs = projectIds.flatMap((id) => createCodeReviewStore(id).listRunSummaries());
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ runs }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
console.log(chalk.dim("No review runs found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
printRun(run);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,492 +1,47 @@
|
|||
/**
|
||||
* `ao setup openclaw` — interactive wizard + non-interactive mode
|
||||
* for wiring AO notifications to an OpenClaw gateway.
|
||||
*
|
||||
* Interactive: ao setup openclaw (human in terminal)
|
||||
* Programmatic: ao setup openclaw --url X --token Y --non-interactive
|
||||
* (OpenClaw agent calling via exec tool)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { parse as yamlParse, parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
import {
|
||||
probeGateway,
|
||||
validateToken,
|
||||
detectOpenClawInstallation,
|
||||
DEFAULT_OPENCLAW_URL,
|
||||
HOOKS_PATH,
|
||||
} from "../lib/openclaw-probe.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SetupAbortedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SetupAbortedError";
|
||||
}
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
url?: string;
|
||||
token?: string;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
}
|
||||
|
||||
type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all";
|
||||
|
||||
interface ResolvedConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
routingPreset: OpenClawRoutingPreset;
|
||||
}
|
||||
|
||||
const OPENCLAW_ROUTING_PRESETS = {
|
||||
"urgent-only": ["urgent"],
|
||||
"urgent-action": ["urgent", "action"],
|
||||
all: ["urgent", "action", "warning", "info"],
|
||||
} as const;
|
||||
|
||||
const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset {
|
||||
return value === "urgent-only" || value === "urgent-action" || value === "all";
|
||||
}
|
||||
|
||||
function normalizeOpenClawHooksUrl(url: string): string {
|
||||
const normalized = url.trim().replace(/\/+$/, "");
|
||||
return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive prompts (dynamic import to keep @clack/prompts optional)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function interactiveSetup(existingUrl?: string): Promise<ResolvedConfig> {
|
||||
const clack = await import("@clack/prompts");
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw ")));
|
||||
|
||||
// --- Step 1: Gateway URL ---------------------------------------------------
|
||||
const defaultUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`;
|
||||
let detectedUrl: string | undefined;
|
||||
|
||||
const spin = clack.spinner();
|
||||
spin.start("Detecting OpenClaw gateway on localhost...");
|
||||
|
||||
const probe = await probeGateway(DEFAULT_OPENCLAW_URL);
|
||||
if (probe.reachable) {
|
||||
detectedUrl = defaultUrl;
|
||||
spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`);
|
||||
} else {
|
||||
spin.stop("No OpenClaw gateway detected on localhost");
|
||||
}
|
||||
|
||||
const urlInput = await clack.text({
|
||||
message: "OpenClaw webhook URL:",
|
||||
placeholder: defaultUrl,
|
||||
initialValue: existingUrl ?? detectedUrl ?? defaultUrl,
|
||||
validate: (v) => {
|
||||
if (!v) return "URL is required";
|
||||
if (!v.startsWith("http://") && !v.startsWith("https://"))
|
||||
return "Must start with http:// or https://";
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(urlInput)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
// Normalize: ensure URL ends with /hooks/agent
|
||||
const url = normalizeOpenClawHooksUrl(urlInput as string);
|
||||
|
||||
// --- Step 2: Token ---------------------------------------------------------
|
||||
const envToken = process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
let tokenValue: string;
|
||||
|
||||
if (envToken) {
|
||||
const useEnv = await clack.confirm({
|
||||
message: `Found OPENCLAW_HOOKS_TOKEN in environment. Use it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (clack.isCancel(useEnv)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
if (useEnv) {
|
||||
tokenValue = envToken;
|
||||
} else {
|
||||
const input = await clack.password({
|
||||
message: "Enter your OpenClaw hooks token:",
|
||||
validate: (v) => (!v ? "Token is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(input)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
tokenValue = input as string;
|
||||
}
|
||||
} else {
|
||||
const generatedToken = randomBytes(32).toString("base64url");
|
||||
const tokenChoice = await clack.select({
|
||||
message: "How would you like to set the hooks token?",
|
||||
options: [
|
||||
{ value: "generate", label: "Auto-generate a secure token (recommended)" },
|
||||
{ value: "manual", label: "Enter an existing token manually" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(tokenChoice)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
if (tokenChoice === "manual") {
|
||||
const input = await clack.password({
|
||||
message: "Enter your OpenClaw hooks token:",
|
||||
validate: (v) => (!v ? "Token is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(input)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
tokenValue = input as string;
|
||||
} else {
|
||||
tokenValue = generatedToken;
|
||||
clack.log.success(`Generated token: ${chalk.dim(tokenValue.slice(0, 8))}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 3: Validate ------------------------------------------------------
|
||||
spin.start("Validating token against gateway...");
|
||||
|
||||
const validation = await validateToken(url, tokenValue);
|
||||
if (validation.valid) {
|
||||
spin.stop("Token validated — connection works!");
|
||||
} else {
|
||||
spin.stop(`Validation failed: ${validation.error}`);
|
||||
|
||||
const cont = await clack.confirm({
|
||||
message: "Save config anyway? (you can fix the token later)",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(cont) || !cont) {
|
||||
clack.cancel("Setup cancelled. Fix the issue and retry.");
|
||||
throw new SetupAbortedError("Setup cancelled. Fix the issue and retry.");
|
||||
}
|
||||
}
|
||||
|
||||
const routingPreset = await clack.select({
|
||||
message: "Which notifications should OpenClaw receive?",
|
||||
initialValue: "urgent-action",
|
||||
options: [
|
||||
{ value: "urgent-action", label: "Urgent + action (recommended)" },
|
||||
{ value: "urgent-only", label: "Urgent only" },
|
||||
{ value: "all", label: "All priorities" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(routingPreset)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SetupAbortedError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
token: tokenValue,
|
||||
routingPreset: routingPreset as OpenClawRoutingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-interactive path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function nonInteractiveSetup(opts: SetupOptions): Promise<ResolvedConfig> {
|
||||
let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"];
|
||||
const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
|
||||
// Auto-detect OpenClaw if no URL was given explicitly
|
||||
if (!rawUrl) {
|
||||
const installation = await detectOpenClawInstallation();
|
||||
if (installation.state === "running") {
|
||||
rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`;
|
||||
console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`));
|
||||
} else {
|
||||
throw new SetupAbortedError(
|
||||
"Error: OpenClaw gateway not reachable and no --url provided.\n" +
|
||||
" Start OpenClaw first, or pass --url explicitly:\n" +
|
||||
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let url = rawUrl;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
throw new SetupAbortedError("Error: --url must start with http:// or https://");
|
||||
}
|
||||
|
||||
// Normalize: ensure URL ends with /hooks/agent
|
||||
url = normalizeOpenClawHooksUrl(url);
|
||||
|
||||
const resolvedToken = token ?? randomBytes(32).toString("base64url");
|
||||
if (!token) {
|
||||
console.log(chalk.dim("No token provided — auto-generated a secure token."));
|
||||
}
|
||||
|
||||
// Skip pre-write validation — on fresh installs the gateway won't have the
|
||||
// token yet. We write both configs first, then the user restarts the gateway.
|
||||
console.log(chalk.dim("Skipping pre-validation (token will be written to both configs)."));
|
||||
|
||||
const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action";
|
||||
|
||||
return { url, token: resolvedToken, routingPreset };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function writeOpenClawConfig(
|
||||
configPath: string,
|
||||
resolved: ResolvedConfig,
|
||||
nonInteractive: boolean,
|
||||
): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
|
||||
// Use parseDocument to preserve YAML comments during round-trip
|
||||
const doc = parseDocument(rawYaml);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawConfig = (doc.toJS() as Record<string, any>) ?? {};
|
||||
|
||||
// Write the env-var placeholder so the raw token is never committed to
|
||||
// version control. ao setup openclaw exports the real value to the shell
|
||||
// profile; the notifier plugin resolves it at runtime (env var → openclaw.json
|
||||
// fallback for daemon contexts where the shell profile isn't sourced).
|
||||
if (!rawConfig.notifiers) rawConfig.notifiers = {};
|
||||
rawConfig.notifiers.openclaw = {
|
||||
plugin: "openclaw",
|
||||
url: resolved.url,
|
||||
token: "$" + "{OPENCLAW_HOOKS_TOKEN}", // env-var placeholder, not a JS template
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
wakeMode: "now",
|
||||
};
|
||||
|
||||
// Add "openclaw" to defaults.notifiers if not already present
|
||||
if (!rawConfig.defaults) rawConfig.defaults = {};
|
||||
if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = [];
|
||||
if (!Array.isArray(rawConfig.defaults.notifiers)) {
|
||||
rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers];
|
||||
}
|
||||
if (!rawConfig.defaults.notifiers.includes("openclaw")) {
|
||||
rawConfig.defaults.notifiers.push("openclaw");
|
||||
}
|
||||
|
||||
const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter(
|
||||
(name) => name !== "openclaw",
|
||||
);
|
||||
|
||||
// AO routes notifications by priority. Preserve existing notifiers and only
|
||||
// adjust OpenClaw membership across the standard priority buckets.
|
||||
if (!rawConfig.notificationRouting) {
|
||||
const base = [...new Set(defaultsWithoutOpenClaw)];
|
||||
rawConfig.notificationRouting = {
|
||||
urgent: [...base],
|
||||
action: [...base],
|
||||
warning: [...base],
|
||||
info: [...base],
|
||||
};
|
||||
} else if (typeof rawConfig.notificationRouting === "object") {
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
if (!Array.isArray(rawConfig.notificationRouting[priority])) {
|
||||
rawConfig.notificationRouting[priority] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]);
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
const list = rawConfig.notificationRouting[priority] as string[];
|
||||
rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw");
|
||||
if (selectedPriorities.has(priority)) {
|
||||
rawConfig.notificationRouting[priority].push("openclaw");
|
||||
}
|
||||
}
|
||||
|
||||
// Update the document tree from the modified plain object while preserving comments
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
doc.setIn(["notifiers"], rawConfig.notifiers);
|
||||
doc.setIn(["defaults"], rawConfig.defaults);
|
||||
doc.setIn(["notificationRouting"], rawConfig.notificationRouting);
|
||||
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log(chalk.green(`✓ Config written to ${configPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the hooks block into ~/.openclaw/openclaw.json.
|
||||
* Returns true on success, false on failure (caller should fall back to
|
||||
* printing manual instructions).
|
||||
*/
|
||||
function writeOpenClawJsonConfig(token: string): boolean {
|
||||
try {
|
||||
const openclawDir = join(homedir(), ".openclaw");
|
||||
const openclawJsonPath = join(openclawDir, "openclaw.json");
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(openclawJsonPath)) {
|
||||
const raw = readFileSync(openclawJsonPath, "utf-8");
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} else if (!existsSync(openclawDir)) {
|
||||
mkdirSync(openclawDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Merge the hooks block (preserve other existing keys in hooks if any)
|
||||
const existingHooks = (config.hooks as Record<string, unknown> | undefined) ?? {};
|
||||
const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes)
|
||||
? existingHooks.allowedSessionKeyPrefixes.filter(
|
||||
(prefix): prefix is string => typeof prefix === "string",
|
||||
)
|
||||
: [];
|
||||
const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:")
|
||||
? existingPrefixes
|
||||
: [...existingPrefixes, "hook:"];
|
||||
|
||||
config.hooks = {
|
||||
...existingHooks,
|
||||
enabled: true,
|
||||
token,
|
||||
allowRequestSessionKey: true,
|
||||
allowedSessionKeyPrefixes,
|
||||
};
|
||||
|
||||
writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `export OPENCLAW_HOOKS_TOKEN=...` to the user's shell profile
|
||||
* (~/.zshrc or ~/.bashrc). Skips if the export line already exists.
|
||||
* Returns the profile path on success, undefined on failure.
|
||||
*/
|
||||
function writeShellExport(token: string): string | undefined {
|
||||
try {
|
||||
const shell = process.env["SHELL"] ?? "";
|
||||
const profileName = shell.endsWith("/zsh") ? ".zshrc" : ".bashrc";
|
||||
const profilePath = join(homedir(), profileName);
|
||||
|
||||
// Sanitize token: escape shell-special characters to prevent injection
|
||||
// when the profile is sourced. Single-quote the value and escape any
|
||||
// embedded single quotes (the only character that breaks '...' quoting).
|
||||
const safeToken = token.replace(/'/g, "'\\''");
|
||||
const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`;
|
||||
|
||||
// Check if it already exists (use the same regex for detection and replacement
|
||||
// to avoid silent no-ops when the line is commented, lacks the export prefix,
|
||||
// or has leading whitespace)
|
||||
// Negative lookahead excludes commented lines (e.g. # export OPENCLAW_HOOKS_TOKEN=...)
|
||||
const existingExportRegex = /^(?!\s*#)\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m;
|
||||
if (existsSync(profilePath)) {
|
||||
const content = readFileSync(profilePath, "utf-8");
|
||||
if (existingExportRegex.test(content)) {
|
||||
// Replace the existing line
|
||||
const updated = content.replace(existingExportRegex, exportLine);
|
||||
writeFileSync(profilePath, updated);
|
||||
return profilePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Append
|
||||
const prefix = existsSync(profilePath) ? "\n" : "";
|
||||
writeFileSync(profilePath, `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, {
|
||||
flag: "a",
|
||||
});
|
||||
return profilePath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function printOpenClawInstructions(
|
||||
nonInteractive: boolean,
|
||||
openclawConfigWritten: boolean,
|
||||
shellProfilePath: string | undefined,
|
||||
): void {
|
||||
if (openclawConfigWritten) {
|
||||
// Both configs written automatically
|
||||
if (nonInteractive) {
|
||||
console.log(
|
||||
chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"),
|
||||
);
|
||||
if (shellProfilePath) {
|
||||
console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`));
|
||||
}
|
||||
console.log("Restart OpenClaw gateway to apply.");
|
||||
} else {
|
||||
console.log(`\n${chalk.green.bold("Done — both configs written.")}`);
|
||||
console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block"));
|
||||
console.log(chalk.dim(" ~/.openclaw/openclaw.json — hooks block"));
|
||||
if (shellProfilePath) {
|
||||
console.log(chalk.dim(` ${shellProfilePath} — OPENCLAW_HOOKS_TOKEN export`));
|
||||
}
|
||||
console.log(`\n${chalk.yellow("Restart OpenClaw gateway to apply.")}`);
|
||||
}
|
||||
} else {
|
||||
// Fallback: could not write OpenClaw config, print manual instructions
|
||||
const instructions = `
|
||||
${chalk.bold("OpenClaw-side config required")}
|
||||
|
||||
AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}):
|
||||
|
||||
${chalk.cyan(`{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"token": "<same-token-you-entered-above>",
|
||||
"allowRequestSessionKey": true,
|
||||
"allowedSessionKeyPrefixes": ["hook:"]
|
||||
}
|
||||
}`)}
|
||||
`;
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log("\nOpenClaw-side config required:");
|
||||
console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:");
|
||||
console.log(" hooks.enabled: true");
|
||||
console.log(' hooks.token: "<your-token>"');
|
||||
console.log(" hooks.allowRequestSessionKey: true");
|
||||
console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]');
|
||||
} else {
|
||||
console.log(instructions);
|
||||
}
|
||||
}
|
||||
}
|
||||
DesktopSetupError,
|
||||
runDesktopSetupAction,
|
||||
type DesktopSetupOptions,
|
||||
} from "../lib/desktop-setup.js";
|
||||
import {
|
||||
DashboardSetupError,
|
||||
runDashboardSetupAction,
|
||||
type DashboardSetupOptions,
|
||||
} from "../lib/dashboard-setup.js";
|
||||
import {
|
||||
WebhookSetupError,
|
||||
runWebhookSetupAction,
|
||||
type WebhookSetupOptions,
|
||||
} from "../lib/webhook-setup.js";
|
||||
import {
|
||||
SlackSetupError,
|
||||
runSlackSetupAction,
|
||||
type SlackSetupOptions,
|
||||
} from "../lib/slack-setup.js";
|
||||
import {
|
||||
DiscordSetupError,
|
||||
runDiscordSetupAction,
|
||||
type DiscordSetupOptions,
|
||||
} from "../lib/discord-setup.js";
|
||||
import {
|
||||
ComposioSetupError,
|
||||
runComposioDiscordBotSetupAction,
|
||||
runComposioDiscordWebhookSetupAction,
|
||||
runComposioMailSetupAction,
|
||||
runComposioSlackSetupAction,
|
||||
runComposioSetupAction,
|
||||
type ComposioDiscordBotSetupOptions,
|
||||
type ComposioDiscordWebhookSetupOptions,
|
||||
type ComposioMailSetupOptions,
|
||||
type ComposioSetupOptions,
|
||||
} from "../lib/composio-setup.js";
|
||||
import {
|
||||
OpenClawSetupError,
|
||||
runOpenClawSetupAction,
|
||||
type OpenClawSetupOptions,
|
||||
} from "../lib/openclaw-setup.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
|
|
@ -495,24 +50,300 @@ AO config was written successfully. Add this to your OpenClaw config (${chalk.di
|
|||
export function registerSetup(program: Command): void {
|
||||
const setup = program.command("setup").description("Set up integrations with external services");
|
||||
|
||||
setup
|
||||
.command("dashboard")
|
||||
.description("Configure dashboard notification retention and routing")
|
||||
.option("--limit <count>", "Number of latest notifications to retain for the dashboard")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure dashboard notifier config")
|
||||
.option("--non-interactive", "Skip prompts")
|
||||
.option("--force", "Replace a conflicting notifiers.dashboard entry")
|
||||
.option("--status", "Show dashboard notifier setup status")
|
||||
.action(async (opts: DashboardSetupOptions) => {
|
||||
try {
|
||||
await runDashboardSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DashboardSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("desktop")
|
||||
.description("Install and configure the native macOS desktop notifier")
|
||||
.option("--backend <backend>", "Desktop backend: auto | ao-app | terminal-notifier | osascript")
|
||||
.option("--refresh", "Refresh/reconfigure desktop notifier config")
|
||||
.option("--dashboard-url <url>", "Dashboard URL to open from notifications")
|
||||
.option("--app-path <path>", "Custom AO Notifier.app install path")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--no-test", "Skip the setup test notification")
|
||||
.option("--non-interactive", "Skip prompts and fail on config conflicts unless --force is set")
|
||||
.option("--force", "Repair the app install and replace conflicting desktop notifier config")
|
||||
.option("--status", "Show the native desktop notifier install and permission status")
|
||||
.option("--uninstall", "Remove AO Notifier.app without changing AO config")
|
||||
.action(async (opts: DesktopSetupOptions) => {
|
||||
try {
|
||||
await runDesktopSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DesktopSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("webhook")
|
||||
.description("Connect AO notifications to a generic HTTP webhook")
|
||||
.option("--url <url>", "Webhook URL")
|
||||
.option("--auth-token <token>", "Bearer token to store in webhook Authorization header")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure webhook notifier config")
|
||||
.option("--no-test", "Skip the setup test POST")
|
||||
.option("--non-interactive", "Skip prompts and require --url unless --refresh can reuse config")
|
||||
.option("--force", "Replace a conflicting notifiers.webhook entry")
|
||||
.option("--status", "Show webhook notifier setup status and probe the endpoint")
|
||||
.action(async (opts: WebhookSetupOptions) => {
|
||||
try {
|
||||
await runWebhookSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof WebhookSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("slack")
|
||||
.description("Connect AO notifications to a Slack incoming webhook")
|
||||
.option("--webhook-url <url>", "Slack incoming webhook URL")
|
||||
.option(
|
||||
"--channel <name>",
|
||||
"Optional channel name; must match the channel selected for the webhook URL",
|
||||
)
|
||||
.option("--username <name>", "Slack display name for AO messages")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure Slack notifier config")
|
||||
.option("--no-test", "Skip the setup test Slack message")
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Skip prompts and require --webhook-url unless --refresh can reuse config",
|
||||
)
|
||||
.option("--force", "Replace a conflicting notifiers.slack entry")
|
||||
.option("--status", "Show Slack notifier setup status and probe the endpoint")
|
||||
.action(async (opts: SlackSetupOptions) => {
|
||||
try {
|
||||
await runSlackSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof SlackSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("discord")
|
||||
.description("Connect AO notifications to a Discord incoming webhook")
|
||||
.option("--webhook-url <url>", "Discord incoming webhook URL")
|
||||
.option("--username <name>", "Discord display name for AO messages")
|
||||
.option("--avatar-url <url>", "Discord avatar image URL for AO messages")
|
||||
.option("--thread-id <id>", "Discord thread id to post into")
|
||||
.option("--retries <count>", "Retry count for rate limits, network errors, and 5xx")
|
||||
.option("--retry-delay-ms <ms>", "Base retry delay in milliseconds")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--refresh", "Refresh/reconfigure Discord notifier config")
|
||||
.option("--no-test", "Skip the setup test Discord message")
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Skip prompts and require --webhook-url unless --refresh can reuse config",
|
||||
)
|
||||
.option("--force", "Replace a conflicting notifiers.discord entry")
|
||||
.option("--status", "Show Discord notifier setup status and probe the endpoint")
|
||||
.action(async (opts: DiscordSetupOptions) => {
|
||||
try {
|
||||
await runDiscordSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DiscordSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio")
|
||||
.description("Open the interactive Composio notifier setup hub")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id")
|
||||
.option("--slack", "Open Slack setup directly")
|
||||
.option("--discord-webhook", "Open Discord webhook setup directly")
|
||||
.option("--discord-bot", "Open Discord bot setup directly")
|
||||
.option("--gmail", "Open Gmail setup directly")
|
||||
.option("--channel <name-or-id>", "Slack channel name or channel id for scriptable Slack setup")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option(
|
||||
"--connected-account-id <id>",
|
||||
"Existing Composio Slack connected account id for scriptable Slack setup",
|
||||
)
|
||||
.option(
|
||||
"--wait-ms <ms>",
|
||||
"How long to wait for a new Slack connection in scriptable setup",
|
||||
"60000",
|
||||
)
|
||||
.option("--non-interactive", "Skip prompts and fail when multiple accounts need selection")
|
||||
.option("--status", "Show Composio notifier setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio entry")
|
||||
.action(async (opts: ComposioSetupOptions) => {
|
||||
try {
|
||||
await runComposioSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-slack")
|
||||
.description("Connect AO notifications to Slack through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for the Slack connected account")
|
||||
.option("--channel <name-or-id>", "Slack channel name or channel id")
|
||||
.option("--connected-account-id <id>", "Existing Composio Slack connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--wait-ms <ms>", "How long to wait for a new Slack connection", "60000")
|
||||
.option("--non-interactive", "Skip prompts and fail when multiple accounts need selection")
|
||||
.option("--status", "Show Composio Slack setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-slack entry")
|
||||
.action(async (opts: ComposioSetupOptions) => {
|
||||
try {
|
||||
await runComposioSlackSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-discord")
|
||||
.description("Connect AO notifications to Discord webhooks through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for tool execution")
|
||||
.option("--webhook-url <url>", "Discord webhook URL")
|
||||
.option("--connected-account-id <id>", "Existing Composio Discord webhook connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--non-interactive", "Skip prompts")
|
||||
.option("--status", "Show Composio Discord webhook setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-discord entry")
|
||||
.action(async (opts: ComposioDiscordWebhookSetupOptions) => {
|
||||
try {
|
||||
await runComposioDiscordWebhookSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-discord-bot")
|
||||
.description("Connect AO notifications to a Discord bot through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for the Discord connected account")
|
||||
.option("--channel-id <id>", "Discord channel id")
|
||||
.option("--bot-token <token>", "Discord bot token used once to create the Composio account")
|
||||
.option("--connected-account-id <id>", "Existing Composio Discord bot connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--non-interactive", "Skip prompts")
|
||||
.option("--status", "Show Composio Discord bot setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-discord-bot entry")
|
||||
.action(async (opts: ComposioDiscordBotSetupOptions) => {
|
||||
try {
|
||||
await runComposioDiscordBotSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("composio-mail")
|
||||
.description("Connect AO notifications to Gmail through Composio")
|
||||
.option("--api-key <key>", "Composio API key (otherwise uses COMPOSIO_API_KEY)")
|
||||
.option("--user-id <id>", "Composio user id for the Gmail connected account")
|
||||
.option("--email-to <email>", "Recipient email address for AO notifications")
|
||||
.option("--connect", "Print a Composio Gmail connect URL when no account exists")
|
||||
.option("--auth-config-id <id>", "Existing Composio Gmail auth config id for --connect")
|
||||
.option("--connected-account-id <id>", "Existing Composio Gmail connected account id")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option("--wait-ms <ms>", "How long to wait for a new Gmail connection with --connect", "60000")
|
||||
.option("--non-interactive", "Skip prompts and fail when multiple accounts need selection")
|
||||
.option("--status", "Show Composio mail setup status without changing config")
|
||||
.option("--force", "Replace a conflicting notifiers.composio-mail entry")
|
||||
.action(async (opts: ComposioMailSetupOptions) => {
|
||||
try {
|
||||
await runComposioMailSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof ComposioSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
setup
|
||||
.command("openclaw")
|
||||
.description("Connect AO notifications to an OpenClaw gateway")
|
||||
.option("--url <url>", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)")
|
||||
.option("--token <token>", "OpenClaw hooks auth token")
|
||||
.option(
|
||||
"--routing-preset <preset>",
|
||||
"OpenClaw routing preset: urgent-only | urgent-action | all",
|
||||
"--token <token>",
|
||||
"Remote/manual fallback token; local setup should read hooks.token from OpenClaw config",
|
||||
)
|
||||
.option("--openclaw-config-path <path>", "OpenClaw config path that contains hooks.token")
|
||||
.option("--routing-preset <preset>", "Routing preset: urgent-only | urgent-action | all")
|
||||
.option(
|
||||
"--non-interactive",
|
||||
"Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)",
|
||||
"Skip prompts — auto-detects OpenClaw if --url not provided and reads token from OpenClaw config",
|
||||
)
|
||||
.action(async (opts: SetupOptions) => {
|
||||
.option("--refresh", "Refresh/reconfigure OpenClaw notifier config")
|
||||
.option("--no-test", "Skip the setup token probe")
|
||||
.option("--force", "Replace a conflicting notifiers.openclaw entry")
|
||||
.option("--status", "Show OpenClaw notifier setup status and probe the gateway")
|
||||
.action(async (opts: OpenClawSetupOptions) => {
|
||||
try {
|
||||
await runSetupAction(opts);
|
||||
} catch (err) {
|
||||
if (err instanceof SetupAbortedError) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.setup_failed",
|
||||
level: "error",
|
||||
summary: "ao setup openclaw failed",
|
||||
data: {
|
||||
aborted: err instanceof OpenClawSetupError && err.exitCode === 0,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof OpenClawSetupError) {
|
||||
console.error(err.message);
|
||||
process.exit(err.exitCode);
|
||||
}
|
||||
|
|
@ -521,80 +352,6 @@ export function registerSetup(program: Command): void {
|
|||
});
|
||||
}
|
||||
|
||||
export async function runSetupAction(opts: SetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
|
||||
// --- Find existing config ------------------------------------------------
|
||||
let configPath: string | undefined;
|
||||
try {
|
||||
const found = findConfigFile();
|
||||
configPath = found ?? undefined;
|
||||
} catch {
|
||||
// no config found
|
||||
}
|
||||
|
||||
if (!configPath) {
|
||||
throw new SetupAbortedError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
// --- Check for existing openclaw config ----------------------------------
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const rawConfig = yamlParse(rawYaml) ?? {};
|
||||
const existingOpenClaw = rawConfig?.notifiers?.openclaw;
|
||||
const existingUrl = existingOpenClaw?.url as string | undefined;
|
||||
|
||||
if (existingOpenClaw && !nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
const reconfigure = await clack.confirm({
|
||||
message: "OpenClaw is already configured. Reconfigure?",
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(reconfigure) || !reconfigure) {
|
||||
console.log(chalk.dim("Keeping existing config."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Run setup -----------------------------------------------------------
|
||||
let resolved: ResolvedConfig;
|
||||
|
||||
if (nonInteractive) {
|
||||
resolved = await nonInteractiveSetup(opts);
|
||||
} else {
|
||||
resolved = await interactiveSetup(existingUrl);
|
||||
}
|
||||
|
||||
// --- Write AO config -----------------------------------------------------
|
||||
writeOpenClawConfig(configPath, resolved, nonInteractive);
|
||||
|
||||
// --- Write OpenClaw config -----------------------------------------------
|
||||
const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token);
|
||||
if (openclawConfigWritten && nonInteractive) {
|
||||
console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json"));
|
||||
}
|
||||
|
||||
// --- Write shell export --------------------------------------------------
|
||||
const shellProfilePath = writeShellExport(resolved.token);
|
||||
if (shellProfilePath && nonInteractive) {
|
||||
console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`));
|
||||
}
|
||||
|
||||
// --- Print instructions --------------------------------------------------
|
||||
printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath);
|
||||
|
||||
// --- Done ----------------------------------------------------------------
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` +
|
||||
chalk.dim(" Run 'ao doctor' to verify the full setup.\n") +
|
||||
chalk.dim(" Restart AO with 'ao stop && ao start' to activate."),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ OpenClaw setup complete."));
|
||||
console.log(chalk.dim("Restart AO to activate: ao stop && ao start"));
|
||||
}
|
||||
export async function runSetupAction(opts: OpenClawSetupOptions): Promise<void> {
|
||||
await runOpenClawSetupAction(opts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { Command } from "commander";
|
|||
import { resolve } from "node:path";
|
||||
import {
|
||||
loadConfig,
|
||||
recordActivityEvent,
|
||||
resolveSpawnTarget,
|
||||
TERMINAL_STATUSES,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -216,6 +217,20 @@ async function spawnSession(
|
|||
throw new Error("Prompt must be at most 4096 characters");
|
||||
}
|
||||
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_invoked",
|
||||
level: "info",
|
||||
summary: `ao spawn invoked${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: agent ?? null,
|
||||
hasPrompt: !!sanitizedPrompt,
|
||||
claimPr: claimOptions?.claimPr ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await sm.spawn({
|
||||
projectId,
|
||||
issueId,
|
||||
|
|
@ -243,9 +258,7 @@ async function spawnSession(
|
|||
const issueLabel = issueId ? ` for issue #${issueId}` : "";
|
||||
const claimLabel = claimedPrUrl ? ` (claimed ${claimedPrUrl})` : "";
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
spinner.succeed(
|
||||
`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`,
|
||||
);
|
||||
spinner.succeed(`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`);
|
||||
console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`);
|
||||
|
||||
// Open terminal tab if requested
|
||||
|
|
@ -262,6 +275,18 @@ async function spawnSession(
|
|||
console.log(`SESSION=${session.id}`);
|
||||
} catch (err) {
|
||||
spinner.fail("Failed to create or initialize session");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `ao spawn failed${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: agent ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -279,7 +304,10 @@ export function registerSpawn(program: Command): void {
|
|||
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
|
||||
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")
|
||||
.option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user")
|
||||
.option("--prompt <text>", "Initial prompt/instructions for the agent (use instead of an issue)")
|
||||
.option(
|
||||
"--prompt <text>",
|
||||
"Initial prompt/instructions for the agent (use instead of an issue)",
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
issue: string | undefined,
|
||||
|
|
@ -326,8 +354,34 @@ export function registerSpawn(program: Command): void {
|
|||
try {
|
||||
await runSpawnPreflight(config, projectId, claimOptions);
|
||||
await ensureAOPollingProject(projectId);
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `ao spawn preflight failed${issueId ? ` for issue ${issueId}` : ""}`,
|
||||
data: {
|
||||
issueId: issueId ?? null,
|
||||
agent: opts.agent ?? null,
|
||||
claimPr: claimOptions.claimPr ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt);
|
||||
try {
|
||||
await spawnSession(
|
||||
config,
|
||||
projectId,
|
||||
issueId,
|
||||
opts.open,
|
||||
opts.agent,
|
||||
claimOptions,
|
||||
opts.prompt,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
|
|
@ -386,9 +440,7 @@ export function registerBatchSpawn(program: Command): void {
|
|||
console.log(banner("BATCH SESSION SPAWNER"));
|
||||
console.log();
|
||||
for (const [pid, items] of groups) {
|
||||
console.log(
|
||||
` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`,
|
||||
);
|
||||
console.log(` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
|
|
@ -404,6 +456,17 @@ export function registerBatchSpawn(program: Command): void {
|
|||
await runSpawnPreflight(config, groupProjectId);
|
||||
await ensureAOPollingProject(groupProjectId);
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: groupProjectId,
|
||||
source: "cli",
|
||||
kind: "cli.spawn_failed",
|
||||
level: "error",
|
||||
summary: `batch-spawn preflight failed for group`,
|
||||
data: {
|
||||
batchSize: items.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
findPidByPort,
|
||||
killProcessTree,
|
||||
loadLocalProjectConfigDetailed,
|
||||
recordActivityEvent,
|
||||
registerProjectInGlobalConfig,
|
||||
getGlobalConfigPath,
|
||||
type OrchestratorConfig,
|
||||
|
|
@ -120,6 +121,17 @@ import { projectSessionUrl } from "../lib/routes.js";
|
|||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
class CliFailureEventRecordedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "CliFailureEventRecordedError";
|
||||
}
|
||||
}
|
||||
|
||||
function isCliFailureEventRecordedError(err: unknown): boolean {
|
||||
return err instanceof CliFailureEventRecordedError;
|
||||
}
|
||||
|
||||
function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig {
|
||||
const localConfig = loadLocalProjectConfigDetailed(projectPath);
|
||||
if (localConfig.kind === "loaded") {
|
||||
|
|
@ -170,6 +182,15 @@ async function registerFlatConfig(configPath: string): Promise<string | null> {
|
|||
...(repo ? { repo } : {}),
|
||||
});
|
||||
|
||||
recordActivityEvent({
|
||||
projectId: registeredProjectId,
|
||||
source: "cli",
|
||||
kind: "cli.config_migrated",
|
||||
level: "info",
|
||||
summary: `flat config registered into global config`,
|
||||
data: { projectPath, configPath },
|
||||
});
|
||||
|
||||
console.log(chalk.green(` ✓ Registered "${registeredProjectId}"\n`));
|
||||
return registeredProjectId;
|
||||
}
|
||||
|
|
@ -958,10 +979,21 @@ async function runStartup(
|
|||
}
|
||||
} catch (err) {
|
||||
spinner.fail("Orchestrator setup failed");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `orchestrator setup failed`,
|
||||
data: {
|
||||
reason: "orchestrator_setup",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
throw new CliFailureEventRecordedError(
|
||||
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
|
|
@ -971,14 +1003,25 @@ async function runStartup(
|
|||
if (shouldStartLifecycle) {
|
||||
try {
|
||||
spinner.start("Starting project supervisor");
|
||||
await startProjectSupervisor();
|
||||
await startProjectSupervisor({ configPath: config.configPath });
|
||||
spinner.succeed("Lifecycle project supervisor started");
|
||||
} catch (err) {
|
||||
spinner.fail("Project supervisor failed to start");
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `project supervisor failed to start`,
|
||||
data: {
|
||||
reason: "supervisor_start",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (dashboardProcess) {
|
||||
dashboardProcess.kill();
|
||||
}
|
||||
throw new Error(
|
||||
throw new CliFailureEventRecordedError(
|
||||
`Failed to start project supervisor: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
|
|
@ -992,12 +1035,21 @@ async function runStartup(
|
|||
if (lastStop && lastStop.sessionIds.length > 0) {
|
||||
const stoppedAgo = `stopped at ${new Date(lastStop.stoppedAt).toLocaleString()}`;
|
||||
const otherProjects = lastStop.otherProjects ?? [];
|
||||
const restoreProjectBySessionId = new Map<string, string>();
|
||||
|
||||
// Build flat list of all sessions to restore, grouped for display
|
||||
const allRestoreSessions: string[] = [
|
||||
...(lastStop.projectId === projectId ? lastStop.sessionIds : []),
|
||||
...otherProjects.flatMap((p) => p.sessionIds),
|
||||
];
|
||||
for (const sessionId of lastStop.sessionIds) {
|
||||
restoreProjectBySessionId.set(sessionId, lastStop.projectId);
|
||||
}
|
||||
for (const otherProject of otherProjects) {
|
||||
for (const sessionId of otherProject.sessionIds) {
|
||||
restoreProjectBySessionId.set(sessionId, otherProject.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
// Display grouped by project
|
||||
const currentProjectSessions = lastStop.projectId === projectId ? lastStop.sessionIds : [];
|
||||
|
|
@ -1023,6 +1075,17 @@ async function runStartup(
|
|||
if (allRestoreSessions.length > 0) {
|
||||
const shouldRestore = await promptConfirm("Restore these sessions?", true);
|
||||
if (shouldRestore) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_started",
|
||||
level: "info",
|
||||
summary: `restoring ${allRestoreSessions.length} session(s) from last-stop`,
|
||||
data: {
|
||||
sessionCount: allRestoreSessions.length,
|
||||
stoppedAt: lastStop.stoppedAt,
|
||||
},
|
||||
});
|
||||
// Use global config so the session manager can see all projects
|
||||
let restoreConfig = config;
|
||||
if (otherProjects.length > 0) {
|
||||
|
|
@ -1047,11 +1110,33 @@ async function runStartup(
|
|||
restoredCount++;
|
||||
} catch (err) {
|
||||
failedSessionIds.add(sessionId);
|
||||
const restoreProjectId = restoreProjectBySessionId.get(sessionId) ?? projectId;
|
||||
recordActivityEvent({
|
||||
projectId: restoreProjectId,
|
||||
sessionId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_session_failed",
|
||||
level: "warn",
|
||||
summary: `failed to restore session`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
warnings.push(
|
||||
` Warning: could not restore ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.restore_completed",
|
||||
level: "info",
|
||||
summary: `restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
data: {
|
||||
requested: allRestoreSessions.length,
|
||||
restored: restoredCount,
|
||||
failed: failedSessionIds.size,
|
||||
},
|
||||
});
|
||||
if (restoredCount === allRestoreSessions.length) {
|
||||
restoreSpinner.succeed(
|
||||
`Restored ${restoredCount}/${allRestoreSessions.length} session(s)`,
|
||||
|
|
@ -1104,7 +1189,15 @@ async function runStartup(
|
|||
await clearLastStop();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_read_failed",
|
||||
level: "warn",
|
||||
summary: `failed to read or process last-stop state during startup`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
// Non-fatal: don't block startup if last-stop handling fails
|
||||
}
|
||||
}
|
||||
|
|
@ -1380,6 +1473,21 @@ export function registerStart(program: Command): void {
|
|||
reapOrphans?: boolean;
|
||||
},
|
||||
) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.start_invoked",
|
||||
level: "info",
|
||||
summary: "ao start invoked",
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
dashboard: opts?.dashboard !== false,
|
||||
orchestrator: opts?.orchestrator !== false,
|
||||
rebuild: opts?.rebuild === true,
|
||||
dev: opts?.dev === true,
|
||||
interactive: opts?.interactive === true,
|
||||
},
|
||||
});
|
||||
|
||||
let releaseStartupLock: (() => void) | undefined;
|
||||
let startupLockReleased = false;
|
||||
const unlockStartup = (): void => {
|
||||
|
|
@ -1512,6 +1620,13 @@ export function registerStart(program: Command): void {
|
|||
// Resolve happens below; the suffix mutation runs after.
|
||||
startNewOrchestrator = true;
|
||||
} else if (choice === "restart") {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.daemon_restart",
|
||||
level: "info",
|
||||
summary: `user chose restart, killing existing daemon`,
|
||||
data: { existingPid: running.pid, existingPort: running.port },
|
||||
});
|
||||
await killExistingDaemon(running);
|
||||
console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n"));
|
||||
running = null;
|
||||
|
|
@ -1671,6 +1786,18 @@ export function registerStart(program: Command): void {
|
|||
// Ctrl+C and `ao stop` (which sends SIGTERM) perform a full
|
||||
// graceful shutdown via the handler installed inside runStartup().
|
||||
} catch (err) {
|
||||
if (!isCliFailureEventRecordedError(err)) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.start_failed",
|
||||
level: "error",
|
||||
summary: `ao start action failed`,
|
||||
data: {
|
||||
reason: "outer",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
|
|
@ -1742,6 +1869,17 @@ export function registerStop(program: Command): void {
|
|||
.option("--purge-session", "Delete mapped OpenCode session when stopping")
|
||||
.option("--all", "Stop all running AO instances")
|
||||
.action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_invoked",
|
||||
level: "info",
|
||||
summary: "ao stop invoked",
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
all: opts.all === true,
|
||||
purgeSession: opts.purgeSession === true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Check running.json first
|
||||
const running = await getRunning();
|
||||
|
|
@ -1818,6 +1956,15 @@ export function registerStop(program: Command): void {
|
|||
killedSessionIds.push(session.id);
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId ?? _projectId,
|
||||
sessionId: session.id,
|
||||
source: "cli",
|
||||
kind: "cli.stop_session_failed",
|
||||
level: "warn",
|
||||
summary: `failed to kill session during ao stop`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
warnings.push(
|
||||
` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
|
|
@ -1862,12 +2009,48 @@ export function registerStop(program: Command): void {
|
|||
otherProjects.push({ projectId: pid, sessionIds: ids });
|
||||
}
|
||||
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: _projectId,
|
||||
sessionIds: killedSessionIds.filter((id) => targetActive.some((s) => s.id === id)),
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
const targetSessionIds = killedSessionIds.filter((id) =>
|
||||
targetActive.some((s) => s.id === id),
|
||||
);
|
||||
try {
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: _projectId,
|
||||
sessionIds: targetSessionIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_written",
|
||||
level: "info",
|
||||
summary: `last-stop state written with ${killedSessionIds.length} session(s)`,
|
||||
data: {
|
||||
targetSessionCount: targetSessionIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_write_failed",
|
||||
level: "error",
|
||||
summary: `failed to write last-stop state during ao stop`,
|
||||
data: {
|
||||
targetSessionCount: targetSessionIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
|
|
@ -1894,7 +2077,29 @@ export function registerStop(program: Command): void {
|
|||
// 0x800700e8). No-op on non-Windows.
|
||||
await sweepWindowsPtyHostsBeforeParentKill();
|
||||
await sweepRegisteredDaemonChildren(running.pid);
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
try {
|
||||
await killProcessTree(running.pid, "SIGTERM");
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.daemon_killed",
|
||||
level: "info",
|
||||
summary: `SIGTERM sent to parent ao start`,
|
||||
data: { pid: running.pid, port: running.port },
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: _projectId,
|
||||
source: "cli",
|
||||
kind: "cli.daemon_killed",
|
||||
level: "warn",
|
||||
summary: `parent ao start was already dead`,
|
||||
data: {
|
||||
pid: running.pid,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
await unregister();
|
||||
} else {
|
||||
await sweepRegisteredDaemonChildren();
|
||||
|
|
@ -1914,6 +2119,16 @@ export function registerStop(program: Command): void {
|
|||
console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`));
|
||||
}
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stop_failed",
|
||||
level: "error",
|
||||
summary: `ao stop action failed`,
|
||||
data: {
|
||||
projectArg: projectArg ?? null,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (err instanceof Error) {
|
||||
console.error(chalk.red("\nError:"), err.message);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import {
|
|||
loadConfig,
|
||||
getProjectSessionsDir,
|
||||
readAgentReportAuditTrailAsync,
|
||||
createCodeReviewStore,
|
||||
type CodeReviewRunStatus,
|
||||
type CodeReviewRunSummary,
|
||||
} from "@aoagents/ao-core";
|
||||
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
|
||||
import {
|
||||
|
|
@ -61,6 +64,24 @@ interface StatusOptions {
|
|||
reports?: string;
|
||||
}
|
||||
|
||||
interface ProjectReviewStatus {
|
||||
projectId: string;
|
||||
runs: CodeReviewRunSummary[];
|
||||
runCount: number;
|
||||
activeRunCount: number;
|
||||
openFindingCount: number;
|
||||
}
|
||||
|
||||
const REVIEW_ATTENTION_STATUSES: ReadonlySet<CodeReviewRunStatus> = new Set([
|
||||
"queued",
|
||||
"preparing",
|
||||
"running",
|
||||
"needs_triage",
|
||||
"sent_to_agent",
|
||||
"waiting_update",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
/** Parse --reports value: "full" → Infinity, positive integer → N, undefined → 0 (off). */
|
||||
function parseReportsLimit(value: string | undefined): number {
|
||||
if (value === undefined) return 0;
|
||||
|
|
@ -89,6 +110,21 @@ function maybeClearScreen(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function gatherProjectReviewStatus(projectId: string): ProjectReviewStatus {
|
||||
const runs = createCodeReviewStore(projectId).listRunSummaries();
|
||||
const activeRunCount = runs.filter(
|
||||
(run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0,
|
||||
).length;
|
||||
const openFindingCount = runs.reduce((sum, run) => sum + run.openFindingCount, 0);
|
||||
return {
|
||||
projectId,
|
||||
runs,
|
||||
runCount: runs.length,
|
||||
activeRunCount,
|
||||
openFindingCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function gatherSessionInfo(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
|
|
@ -306,6 +342,44 @@ function printOrchestratorRow(info: SessionInfo): void {
|
|||
printReportRows(info.reports, " ");
|
||||
}
|
||||
|
||||
function printReviewStatus(summary: ProjectReviewStatus): void {
|
||||
if (summary.runCount === 0) return;
|
||||
|
||||
const findings =
|
||||
summary.openFindingCount === 1
|
||||
? "1 open finding"
|
||||
: `${summary.openFindingCount} open findings`;
|
||||
const active =
|
||||
summary.activeRunCount === 1 ? "1 active" : `${summary.activeRunCount} active`;
|
||||
|
||||
console.log(
|
||||
` ${chalk.magenta("Reviews:")} ${summary.runCount} run${summary.runCount !== 1 ? "s" : ""} · ${active} · ${findings}`,
|
||||
);
|
||||
|
||||
const visibleRuns = summary.runs
|
||||
.filter((run) => REVIEW_ATTENTION_STATUSES.has(run.status) || run.openFindingCount > 0)
|
||||
.slice(0, 5);
|
||||
|
||||
for (const run of visibleRuns) {
|
||||
const findingText =
|
||||
run.openFindingCount === 1
|
||||
? "1 open finding"
|
||||
: `${run.openFindingCount} open findings`;
|
||||
console.log(
|
||||
` ${chalk.green(run.reviewerSessionId)} ${chalk.dim(run.status)} → ${chalk.cyan(run.linkedSessionId)} ${chalk.dim(findingText)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const hiddenCount = summary.runs.length - visibleRuns.length;
|
||||
if (hiddenCount > 0) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${hiddenCount} more review run${hiddenCount !== 1 ? "s" : ""}. Use \`ao review list ${summary.projectId}\`.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStatus(program: Command): void {
|
||||
program
|
||||
.command("status")
|
||||
|
|
@ -406,8 +480,12 @@ export function registerStatus(program: Command): void {
|
|||
// Show projects that have no sessions too (if not filtered)
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
const jsonOutput: SessionInfo[] = [];
|
||||
const reviewOutput: CodeReviewRunSummary[] = [];
|
||||
let totalWorkers = 0;
|
||||
let totalOrchestrators = 0;
|
||||
let totalReviewRuns = 0;
|
||||
let totalActiveReviewRuns = 0;
|
||||
let totalOpenReviewFindings = 0;
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const projectConfig = config.projects[projectId];
|
||||
|
|
@ -416,6 +494,11 @@ export function registerStatus(program: Command): void {
|
|||
const projectSessions = (byProject.get(projectId) ?? []).sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const reviewStatus = gatherProjectReviewStatus(projectId);
|
||||
reviewOutput.push(...reviewStatus.runs);
|
||||
totalReviewRuns += reviewStatus.runCount;
|
||||
totalActiveReviewRuns += reviewStatus.activeRunCount;
|
||||
totalOpenReviewFindings += reviewStatus.openFindingCount;
|
||||
|
||||
// Resolve agent and SCM for this project via the shared registry
|
||||
const agentName = projectConfig.agent ?? config.defaults.agent;
|
||||
|
|
@ -429,6 +512,7 @@ export function registerStatus(program: Command): void {
|
|||
if (projectSessions.length === 0) {
|
||||
if (!opts.json) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
printReviewStatus(reviewStatus);
|
||||
console.log();
|
||||
}
|
||||
continue;
|
||||
|
|
@ -462,6 +546,7 @@ export function registerStatus(program: Command): void {
|
|||
|
||||
if (workers.length === 0) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
printReviewStatus(reviewStatus);
|
||||
console.log();
|
||||
continue;
|
||||
}
|
||||
|
|
@ -470,13 +555,23 @@ export function registerStatus(program: Command): void {
|
|||
for (const info of workers) {
|
||||
printSessionRow(info);
|
||||
}
|
||||
printReviewStatus(reviewStatus);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ data: jsonOutput, meta: { hiddenTerminatedCount } },
|
||||
{
|
||||
data: jsonOutput,
|
||||
reviews: reviewOutput,
|
||||
meta: {
|
||||
hiddenTerminatedCount,
|
||||
reviewRunCount: totalReviewRuns,
|
||||
activeReviewRunCount: totalActiveReviewRuns,
|
||||
openReviewFindingCount: totalOpenReviewFindings,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
|
|
@ -487,6 +582,12 @@ export function registerStatus(program: Command): void {
|
|||
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
|
||||
(totalOrchestrators > 0
|
||||
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
|
||||
: "") +
|
||||
(totalReviewRuns > 0
|
||||
? ` · ${totalReviewRuns} review run${totalReviewRuns !== 1 ? "s" : ""}` +
|
||||
(totalOpenReviewFindings > 0
|
||||
? ` · ${totalOpenReviewFindings} open finding${totalOpenReviewFindings !== 1 ? "s" : ""}`
|
||||
: "")
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
isWindows,
|
||||
loadConfig,
|
||||
loadGlobalConfig,
|
||||
recordActivityEvent,
|
||||
type Session,
|
||||
} from "@aoagents/ao-core";
|
||||
import { runRepoScript } from "../lib/script-runner.js";
|
||||
|
|
@ -83,6 +84,14 @@ export function registerUpdate(program: Command): void {
|
|||
|
||||
const method = detectInstallMethod();
|
||||
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_invoked",
|
||||
level: "info",
|
||||
summary: `ao update invoked (method: ${method})`,
|
||||
data: { method, options: opts },
|
||||
});
|
||||
|
||||
// Reject git-only flags up front when the install isn't a git source.
|
||||
// Without this, users copy/pasting `ao update --skip-smoke` from older
|
||||
// docs would silently no-op on npm/pnpm/bun installs (the flag would be
|
||||
|
|
@ -225,6 +234,13 @@ async function handleGitUpdate(opts: {
|
|||
try {
|
||||
const exitCode = await runRepoScript("ao-update.sh", args);
|
||||
if (exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed: ao-update.sh exited non-zero`,
|
||||
data: { method: "git", exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}
|
||||
invalidateCache();
|
||||
|
|
@ -233,6 +249,13 @@ async function handleGitUpdate(opts: {
|
|||
error instanceof Error &&
|
||||
error.message.includes("Script not found: ao-update.sh")
|
||||
) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed: ao-update.sh missing from bundled assets`,
|
||||
data: { method: "git", reason: "script_missing" },
|
||||
});
|
||||
console.error(
|
||||
chalk.red(
|
||||
"ao-update.sh is missing from the bundled assets. " +
|
||||
|
|
@ -243,6 +266,16 @@ async function handleGitUpdate(opts: {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (git) failed`,
|
||||
data: {
|
||||
method: "git",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -260,9 +293,34 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
// would overwrite cache.channel before we can read it.
|
||||
const previousChannel = readCachedUpdateInfo(method)?.channel;
|
||||
|
||||
const info = await checkForUpdate({ force: true, channel });
|
||||
let info: Awaited<ReturnType<typeof checkForUpdate>>;
|
||||
try {
|
||||
info = await checkForUpdate({ force: true, channel });
|
||||
} catch (error) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: npm registry lookup threw`,
|
||||
data: {
|
||||
method,
|
||||
channel,
|
||||
reason: "registry_lookup_threw",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!info.latestVersion) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: npm registry lookup returned no version`,
|
||||
data: { method, channel, reason: "registry_unreachable" },
|
||||
});
|
||||
console.error(chalk.red("Could not reach npm registry. Check your network and try again."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -369,6 +427,13 @@ async function handleNpmUpdate(method: InstallMethod): Promise<void> {
|
|||
invalidateCache();
|
||||
console.log(chalk.green("\nUpdate complete."));
|
||||
} else {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.update_failed",
|
||||
level: "error",
|
||||
summary: `ao update (${method}) failed: install command exited non-zero`,
|
||||
data: { method, command, exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,6 +20,10 @@ terminalPort: 14800 # Optional terminal WebSocket port override
|
|||
directTerminalPort: 14801 # Optional direct terminal WebSocket port override
|
||||
readyThresholdMs: 300000 # Ms before "ready" becomes "idle" (default: 5 min)
|
||||
|
||||
observability:
|
||||
logLevel: warn # debug | info | warn | error
|
||||
stderr: false # Mirror structured logs to stderr
|
||||
|
||||
# ── Default plugins ─────────────────────────────────────────────────
|
||||
# These apply to all projects unless overridden per-project.
|
||||
|
||||
|
|
@ -110,6 +114,12 @@ projects:
|
|||
notifiers:
|
||||
desktop:
|
||||
plugin: desktop
|
||||
# Run 'ao setup desktop' on macOS to use AO Notifier.app
|
||||
# backend: ao-app
|
||||
dashboard:
|
||||
plugin: dashboard
|
||||
# Run 'ao setup dashboard' to retain notifications in the web dashboard
|
||||
# limit: 50
|
||||
slack:
|
||||
plugin: slack
|
||||
# Requires SLACK_WEBHOOK_URL env var
|
||||
|
|
@ -119,8 +129,42 @@ notifiers:
|
|||
openclaw:
|
||||
plugin: openclaw
|
||||
# url: http://127.0.0.1:18789/hooks/agent
|
||||
# token: \${OPENCLAW_HOOKS_TOKEN}
|
||||
# openclawConfigPath: ~/.openclaw/openclaw.json
|
||||
# OpenClaw owns hooks.token in openclaw.json
|
||||
# Run 'ao setup openclaw' for guided configuration
|
||||
composio:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio' to connect Slack through Composio
|
||||
# userId: aoagent
|
||||
# connectedAccountId: ca_...
|
||||
# channelName: "#agents"
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
# toolVersion: "20260508_00" # optional Slack override
|
||||
composio-discord:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio-discord' for Discord webhook mode through Composio
|
||||
# defaultApp: discord
|
||||
# mode: webhook
|
||||
# webhookUrl: https://discord.com/api/webhooks/...
|
||||
# userId: aoagent
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
composio-discord-bot:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio-discord-bot' for Discord bot mode through Composio
|
||||
# defaultApp: discord
|
||||
# mode: bot
|
||||
# channelId: "1234567890"
|
||||
# userId: aoagent
|
||||
# connectedAccountId: ca_...
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
composio-mail:
|
||||
plugin: composio
|
||||
# Run 'ao setup composio-mail' to connect Gmail through Composio
|
||||
# defaultApp: gmail
|
||||
# emailTo: alerts@example.com
|
||||
# userId: aoagent
|
||||
# connectedAccountId: ca_...
|
||||
# composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY
|
||||
|
||||
# ── Notification routing (optional) ─────────────────────────────────
|
||||
# Route notifications by priority level.
|
||||
|
|
@ -143,7 +187,7 @@ notificationRouting:
|
|||
# Workspace: worktree, clone
|
||||
# SCM: github, gitlab
|
||||
# Tracker: github, linear, gitlab
|
||||
# Notifier: desktop, discord, slack, webhook, composio, openclaw
|
||||
# Notifier: dashboard, desktop, discord, slack, webhook, composio, openclaw
|
||||
# Terminal: iterm2, web
|
||||
`.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
/** Keys that AO agents commonly need and OpenClaw may already store. */
|
||||
const RESOLVABLE_KEYS = [
|
||||
|
|
@ -66,8 +67,18 @@ function readOpenClawKeys(): Record<string, string> {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed config — silently skip.
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.credential_load_failed",
|
||||
level: "warn",
|
||||
summary: `failed to read or parse ~/.openclaw/openclaw.json`,
|
||||
data: {
|
||||
configPath,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
// Malformed config — silently skip (event surfaces it).
|
||||
}
|
||||
|
||||
return keys;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import {
|
||||
CONFIG_SCHEMA_URL,
|
||||
DEFAULT_DASHBOARD_NOTIFICATION_LIMIT,
|
||||
getDashboardNotificationStorePath,
|
||||
isCanonicalGlobalConfigPath,
|
||||
findConfigFile,
|
||||
normalizeDashboardNotificationLimit,
|
||||
readDashboardNotificationsFromFile,
|
||||
} from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
export interface DashboardSetupOptions {
|
||||
limit?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DashboardConfig {
|
||||
plugin: "dashboard";
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface ResolvedDashboardSetup {
|
||||
limit: number;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class DashboardSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DashboardSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new DashboardSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingDashboard(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["dashboard"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
|
||||
}
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value.trim(), 10)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new DashboardSetupError("Dashboard notification limit must be a number.");
|
||||
}
|
||||
return normalizeDashboardNotificationLimit(parsed);
|
||||
}
|
||||
|
||||
function resolveDashboardRoutingPreset(
|
||||
value: string | undefined,
|
||||
): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "dashboard") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new DashboardSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function toDashboardConfig(resolved: ResolvedDashboardSetup): DashboardConfig {
|
||||
return {
|
||||
plugin: "dashboard",
|
||||
limit: resolved.limit,
|
||||
};
|
||||
}
|
||||
|
||||
function writeDashboardConfig(configPath: string, resolved: ResolvedDashboardSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
notifiers["dashboard"] = toDashboardConfig(resolved);
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "dashboard", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Dashboard setup cancelled.");
|
||||
throw new DashboardSetupError("Dashboard setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingDashboard(
|
||||
plugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (plugin === undefined || plugin === "dashboard" || force) return true;
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new DashboardSetupError(
|
||||
`notifiers.dashboard already uses plugin "${String(plugin)}". Pass --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const answer = await clack.confirm({
|
||||
message: `notifiers.dashboard already uses plugin "${String(plugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(answer)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return answer === true;
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: DashboardSetupOptions,
|
||||
existingDashboard: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedDashboardSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const optionRoutingPreset = resolveDashboardRoutingPreset(opts.routingPreset);
|
||||
const existingLimit = parseLimit(existingDashboard["limit"]);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup dashboard ")));
|
||||
|
||||
while (true) {
|
||||
const limitInput = await clack.text({
|
||||
message: "How many dashboard notifications should AO keep?",
|
||||
placeholder: String(DEFAULT_DASHBOARD_NOTIFICATION_LIMIT),
|
||||
initialValue: stringValue(opts.limit) ?? String(existingLimit),
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseLimit(value);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(limitInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "dashboard", "dashboard", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return {
|
||||
limit: parseLimit(limitInput),
|
||||
routingPreset: routingSelection === "preserve" ? undefined : routingSelection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: DashboardSetupOptions,
|
||||
existingDashboard: Record<string, unknown>,
|
||||
): ResolvedDashboardSetup {
|
||||
const limit =
|
||||
opts.limit !== undefined
|
||||
? parseLimit(opts.limit)
|
||||
: opts.refresh
|
||||
? parseLimit(existingDashboard["limit"])
|
||||
: DEFAULT_DASHBOARD_NOTIFICATION_LIMIT;
|
||||
const routingPreset =
|
||||
resolveDashboardRoutingPreset(opts.routingPreset) ??
|
||||
(opts.refresh ? undefined : "urgent-action");
|
||||
|
||||
return { limit, routingPreset };
|
||||
}
|
||||
|
||||
function printStatus(): void {
|
||||
const context = readConfigContext();
|
||||
const existingDashboard = getExistingDashboard(context.rawConfig);
|
||||
const plugin = stringValue(existingDashboard["plugin"]);
|
||||
const limit = parseLimit(existingDashboard["limit"]);
|
||||
const storePath = getDashboardNotificationStorePath(context.configPath);
|
||||
const records = readDashboardNotificationsFromFile(storePath, limit);
|
||||
const latest = records.at(-1);
|
||||
|
||||
console.log(chalk.bold("Dashboard notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Limit: ${limit}`);
|
||||
console.log(` Store: ${storePath}`);
|
||||
console.log(` Stored: ${records.length}`);
|
||||
console.log(` Latest: ${latest?.receivedAt ?? chalk.dim("none")}`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "dashboard").label}`);
|
||||
}
|
||||
|
||||
export async function runDashboardSetupAction(opts: DashboardSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingDashboard = getExistingDashboard(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingDashboard(
|
||||
existingDashboard["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingDashboard)
|
||||
: await resolveInteractiveSetup(opts, existingDashboard, context.rawConfig);
|
||||
|
||||
writeDashboardConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Dashboard setup complete!")} AO will retain the latest ${resolved.limit} dashboard notifications.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to dashboard --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\nDashboard setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to dashboard --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const APP_NAME = "AO Notifier.app";
|
||||
const EXECUTABLE_NAME = "ao-notifier";
|
||||
const PLACEHOLDER_MARKER_NAME = "ao-notifier-placeholder";
|
||||
const DESKTOP_BACKENDS = ["auto", "ao-app", "terminal-notifier", "osascript"] as const;
|
||||
|
||||
type DesktopBackend = (typeof DESKTOP_BACKENDS)[number];
|
||||
|
||||
export class DesktopSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DesktopSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopSetupOptions {
|
||||
nonInteractive?: boolean;
|
||||
force?: boolean;
|
||||
status?: boolean;
|
||||
uninstall?: boolean;
|
||||
refresh?: boolean;
|
||||
backend?: string;
|
||||
dashboardUrl?: string;
|
||||
appPath?: string;
|
||||
test?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface JsonRecord {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DesktopConfigContext {
|
||||
configPath: string | undefined;
|
||||
rawConfig: Record<string, unknown>;
|
||||
existingDesktop: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedDesktopSetup {
|
||||
backend: DesktopBackend;
|
||||
dashboardUrl?: string;
|
||||
appPath: string;
|
||||
shouldWriteAppPath: boolean;
|
||||
shouldSendTest: boolean;
|
||||
refresh: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
function currentPlatform(): NodeJS.Platform | string {
|
||||
return process.env["AO_DESKTOP_SETUP_PLATFORM"] ?? platform();
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function isDesktopBackend(value: unknown): value is DesktopBackend {
|
||||
return typeof value === "string" && DESKTOP_BACKENDS.includes(value as DesktopBackend);
|
||||
}
|
||||
|
||||
function parseDesktopBackend(value: unknown): DesktopBackend | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (isDesktopBackend(value)) return value;
|
||||
throw new DesktopSetupError(
|
||||
`Invalid desktop backend "${String(value)}". Expected one of: ${DESKTOP_BACKENDS.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function packageDirFromImport(): string | null {
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
return dirname(require.resolve("@aoagents/ao-notifier-macos/package.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getBundledNotifierAppPath(): string | null {
|
||||
const override = process.env["AO_NOTIFIER_MACOS_APP_PATH"];
|
||||
if (override) return override;
|
||||
|
||||
const packageDir = packageDirFromImport();
|
||||
if (packageDir) {
|
||||
return resolve(packageDir, "dist", APP_NAME);
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolve(here, "..", "..", "..", "notifier-macos", "dist", APP_NAME);
|
||||
}
|
||||
|
||||
export function getInstalledNotifierAppPath(): string {
|
||||
return process.env["AO_DESKTOP_APP_INSTALL_PATH"] ?? join(homedir(), "Applications", APP_NAME);
|
||||
}
|
||||
|
||||
export function getNotifierExecutablePath(appPath: string): string {
|
||||
return join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
|
||||
}
|
||||
|
||||
function getNotifierPlaceholderMarkerPath(appPath: string): string {
|
||||
return join(appPath, "Contents", "Resources", PLACEHOLDER_MARKER_NAME);
|
||||
}
|
||||
|
||||
function isPlaceholderNotifierApp(appPath: string): boolean {
|
||||
return existsSync(getNotifierPlaceholderMarkerPath(appPath));
|
||||
}
|
||||
|
||||
function isAppInstalled(appPath = getInstalledNotifierAppPath()): boolean {
|
||||
return existsSync(getNotifierExecutablePath(appPath)) && !isPlaceholderNotifierApp(appPath);
|
||||
}
|
||||
|
||||
function commandExists(command: string): boolean {
|
||||
try {
|
||||
execFileSync(command, ["--version"], { stdio: "ignore", windowsHide: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
||||
}
|
||||
}
|
||||
|
||||
function execNotifierJson(appPath: string, args: string[]): JsonRecord | null {
|
||||
try {
|
||||
const output = execFileSync(getNotifierExecutablePath(appPath), args, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
return JSON.parse(output) as JsonRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: unknown): JsonRecord | null {
|
||||
try {
|
||||
const text = Buffer.isBuffer(output) ? output.toString("utf-8") : String(output ?? "");
|
||||
if (!text.trim()) return null;
|
||||
return JSON.parse(text) as JsonRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatExecError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function permissionDeniedMessage(): string {
|
||||
return (
|
||||
"macOS notification permission is denied for AO Notifier.app.\n" +
|
||||
" Open System Settings > Notifications > AO Notifier and enable Allow Notifications.\n" +
|
||||
" Then rerun: ao setup desktop --force"
|
||||
);
|
||||
}
|
||||
|
||||
function readConfigContext(): DesktopConfigContext {
|
||||
const configPath = findOptionalConfigPath();
|
||||
if (!configPath) {
|
||||
return {
|
||||
configPath,
|
||||
rawConfig: {},
|
||||
existingDesktop: {},
|
||||
};
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const rawConfig = (parseDocument(rawYaml).toJS() as Record<string, unknown>) ?? {};
|
||||
const notifiers = (rawConfig["notifiers"] as Record<string, unknown> | undefined) ?? {};
|
||||
const existingDesktop = (notifiers["desktop"] as Record<string, unknown> | undefined) ?? {};
|
||||
return {
|
||||
configPath,
|
||||
rawConfig,
|
||||
existingDesktop,
|
||||
};
|
||||
}
|
||||
|
||||
function printStatus(): void {
|
||||
const os = currentPlatform();
|
||||
const appPath = getInstalledNotifierAppPath();
|
||||
const installed = isAppInstalled(appPath);
|
||||
const version = installed ? execNotifierJson(appPath, ["--version-json"]) : null;
|
||||
const permission = installed ? execNotifierJson(appPath, ["--permission-status-json"]) : null;
|
||||
const context = readConfigContext();
|
||||
const configBackend = stringValue(context.existingDesktop["backend"]);
|
||||
const configDashboardUrl = stringValue(context.existingDesktop["dashboardUrl"]);
|
||||
const configAppPath = stringValue(context.existingDesktop["appPath"]);
|
||||
|
||||
console.log(chalk.bold("AO desktop notifier"));
|
||||
console.log(` platform: ${os}`);
|
||||
if (context.configPath) {
|
||||
console.log(` config: ${context.configPath}`);
|
||||
}
|
||||
console.log(` config backend: ${configBackend ?? "not configured"}`);
|
||||
console.log(` dashboardUrl: ${configDashboardUrl ?? "not configured"}`);
|
||||
console.log(` config appPath: ${configAppPath ?? "not configured"}`);
|
||||
console.log(` terminal-notifier: ${commandExists("terminal-notifier") ? "available" : "missing"}`);
|
||||
console.log(` osascript: ${commandExists("osascript") ? "available" : "missing"}`);
|
||||
console.log(` installed: ${installed ? "yes" : "no"}`);
|
||||
console.log(` app: ${appPath}`);
|
||||
console.log(` routing: ${getNotifierRoutingState(context.rawConfig, "desktop").label}`);
|
||||
if (version?.["version"]) {
|
||||
console.log(` version: ${String(version["version"])}`);
|
||||
}
|
||||
if (permission?.["status"]) {
|
||||
console.log(` permissions: ${String(permission["status"])}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyBundledApp(targetAppPath = getInstalledNotifierAppPath()): string {
|
||||
if (currentPlatform() !== "darwin") {
|
||||
throw new DesktopSetupError("ao setup desktop is currently only supported on macOS.");
|
||||
}
|
||||
|
||||
const sourceAppPath = getBundledNotifierAppPath();
|
||||
if (!sourceAppPath || !existsSync(getNotifierExecutablePath(sourceAppPath))) {
|
||||
throw new DesktopSetupError(
|
||||
"AO Notifier.app is not built. Run: pnpm --filter @aoagents/ao-notifier-macos build",
|
||||
);
|
||||
}
|
||||
|
||||
if (isPlaceholderNotifierApp(sourceAppPath)) {
|
||||
throw new DesktopSetupError(
|
||||
"AO Notifier.app was built as a non-macOS placeholder and cannot be installed. " +
|
||||
"Rebuild @aoagents/ao-notifier-macos on macOS, or use --backend terminal-notifier.",
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(targetAppPath), { recursive: true });
|
||||
rmSync(targetAppPath, { recursive: true, force: true });
|
||||
cpSync(sourceAppPath, targetAppPath, { recursive: true });
|
||||
|
||||
if (!isAppInstalled(targetAppPath)) {
|
||||
throw new DesktopSetupError(`AO Notifier.app install failed at ${targetAppPath}`);
|
||||
}
|
||||
|
||||
return targetAppPath;
|
||||
}
|
||||
|
||||
function requestPermission(appPath: string): void {
|
||||
let result: JsonRecord | null;
|
||||
|
||||
try {
|
||||
const output = execFileSync(getNotifierExecutablePath(appPath), ["--request-permission"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
result = parseJsonOutput(output);
|
||||
} catch (error) {
|
||||
const failure = error as { stdout?: unknown; stderr?: unknown };
|
||||
result = parseJsonOutput(failure.stdout);
|
||||
if (result?.["status"] === "denied") {
|
||||
throw new DesktopSetupError(permissionDeniedMessage());
|
||||
}
|
||||
|
||||
const stderr = Buffer.isBuffer(failure.stderr)
|
||||
? failure.stderr.toString("utf-8").trim()
|
||||
: String(failure.stderr ?? "").trim();
|
||||
throw new DesktopSetupError(
|
||||
`Could not request macOS notification permission: ${stderr || formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result?.["status"] === "denied") {
|
||||
throw new DesktopSetupError(permissionDeniedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function sendAoAppSetupNotification(appPath: string, dashboardUrl?: string): void {
|
||||
const payload = {
|
||||
title: "AO Notifier",
|
||||
body: "Desktop notifications are ready.",
|
||||
sound: false,
|
||||
defaultOpenUrl: dashboardUrl,
|
||||
event: {
|
||||
id: `desktop-setup-${Date.now()}`,
|
||||
type: "setup.desktop",
|
||||
priority: "info",
|
||||
sessionId: "setup",
|
||||
projectId: "ao",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
actions: dashboardUrl ? [{ label: "Open Dashboard", url: dashboardUrl }] : [],
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64");
|
||||
try {
|
||||
execFileSync(getNotifierExecutablePath(appPath), ["--notify-base64", encoded], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
const failure = error as { stderr?: unknown };
|
||||
const stderr = Buffer.isBuffer(failure.stderr)
|
||||
? failure.stderr.toString("utf-8").trim()
|
||||
: String(failure.stderr ?? "").trim();
|
||||
throw new DesktopSetupError(
|
||||
`Could not send desktop setup test notification: ${stderr || formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sendTerminalNotifierSetupNotification(dashboardUrl?: string): void {
|
||||
const args = ["-title", "AO Notifier", "-message", "Desktop notifications are ready."];
|
||||
if (dashboardUrl) args.push("-open", dashboardUrl);
|
||||
execFileSync("terminal-notifier", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function sendOsascriptSetupNotification(): void {
|
||||
execFileSync(
|
||||
"osascript",
|
||||
["-e", 'display notification "Desktop notifications are ready." with title "AO Notifier"'],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
}
|
||||
|
||||
function findOptionalConfigPath(): string | undefined {
|
||||
try {
|
||||
return findConfigFile() ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingDesktop(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "desktop" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new DesktopSetupError(
|
||||
`notifiers.desktop already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.desktop already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing desktop notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function dashboardUrlFromConfig(rawConfig: Record<string, unknown>): string | undefined {
|
||||
const port = rawConfig["port"];
|
||||
if (typeof port === "number") return `http://localhost:${port}`;
|
||||
if (typeof port === "string" && port.trim().length > 0) return `http://localhost:${port.trim()}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function chooseDesktopBackend(
|
||||
existingBackend: DesktopBackend | undefined,
|
||||
nonInteractive: boolean,
|
||||
): Promise<DesktopBackend> {
|
||||
if (nonInteractive) return existingBackend ?? "ao-app";
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const choice = await clack.select({
|
||||
message: "Choose the desktop notification backend:",
|
||||
options: [
|
||||
{
|
||||
value: "ao-app",
|
||||
label: existingBackend === "ao-app" ? "AO Notifier.app (current)" : "AO Notifier.app (recommended)",
|
||||
hint: "Native macOS app with actions and AO-specific behavior",
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: existingBackend === "auto" ? "Auto fallback (current)" : "Auto fallback",
|
||||
hint: "AO app if installed, then terminal-notifier, then osascript",
|
||||
},
|
||||
{
|
||||
value: "terminal-notifier",
|
||||
label: existingBackend === "terminal-notifier" ? "terminal-notifier (current)" : "terminal-notifier",
|
||||
hint: "Requires Homebrew package; supports click-to-open",
|
||||
},
|
||||
{
|
||||
value: "osascript",
|
||||
label: existingBackend === "osascript" ? "osascript (current)" : "osascript",
|
||||
hint: "Built into macOS; basic notifications only",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice)) {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new DesktopSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
return choice as DesktopBackend;
|
||||
}
|
||||
|
||||
function resolveDashboardUrl(
|
||||
opts: DesktopSetupOptions,
|
||||
rawConfig: Record<string, unknown>,
|
||||
existingDesktop: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
return (
|
||||
stringValue(opts.dashboardUrl) ??
|
||||
dashboardUrlFromConfig(rawConfig) ??
|
||||
stringValue(existingDesktop["dashboardUrl"])
|
||||
);
|
||||
}
|
||||
|
||||
async function maybeInstallTerminalNotifier(nonInteractive: boolean): Promise<void> {
|
||||
if (commandExists("terminal-notifier")) return;
|
||||
|
||||
if (nonInteractive) {
|
||||
throw new DesktopSetupError(
|
||||
"terminal-notifier is not installed. Install it with: brew install terminal-notifier",
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const install = await clack.confirm({
|
||||
message: "terminal-notifier is not installed. Install it with Homebrew now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (clack.isCancel(install) || !install) {
|
||||
throw new DesktopSetupError(
|
||||
"terminal-notifier is required for this backend. Install it with: brew install terminal-notifier",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync("brew", ["install", "terminal-notifier"], { stdio: "inherit" });
|
||||
} catch (error) {
|
||||
throw new DesktopSetupError(
|
||||
`Could not install terminal-notifier with Homebrew: ${formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOsascriptAvailable(): void {
|
||||
if (!commandExists("osascript")) {
|
||||
throw new DesktopSetupError("osascript is not available on this system.");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAutoBackend(appPath: string): DesktopBackend {
|
||||
if (currentPlatform() === "darwin" && isAppInstalled(appPath)) return "ao-app";
|
||||
if (commandExists("terminal-notifier")) return "terminal-notifier";
|
||||
if (commandExists("osascript")) return "osascript";
|
||||
throw new DesktopSetupError(
|
||||
"No desktop notification backend is available. Run `ao setup desktop --backend ao-app`, or install terminal-notifier.",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDesktopSetup(
|
||||
opts: DesktopSetupOptions,
|
||||
context: DesktopConfigContext,
|
||||
nonInteractive: boolean,
|
||||
): Promise<ResolvedDesktopSetup> {
|
||||
const explicitBackend = parseDesktopBackend(opts.backend);
|
||||
const existingBackend = parseDesktopBackend(context.existingDesktop["backend"]);
|
||||
const optionRoutingPreset = resolveDesktopRoutingPreset(opts.routingPreset);
|
||||
|
||||
while (true) {
|
||||
const backend =
|
||||
explicitBackend ??
|
||||
(await chooseDesktopBackend(opts.refresh ? existingBackend : undefined, nonInteractive));
|
||||
const appPath =
|
||||
stringValue(opts.appPath) ??
|
||||
stringValue(context.existingDesktop["appPath"]) ??
|
||||
getInstalledNotifierAppPath();
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(nonInteractive || !context.configPath
|
||||
? opts.refresh
|
||||
? undefined
|
||||
: "all"
|
||||
: await promptNotifierRoutingPreset(
|
||||
await import("@clack/prompts"),
|
||||
context.rawConfig,
|
||||
"desktop",
|
||||
"desktop",
|
||||
() => {
|
||||
throw new DesktopSetupError("Setup cancelled.", 0);
|
||||
},
|
||||
));
|
||||
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return {
|
||||
backend,
|
||||
dashboardUrl: resolveDashboardUrl(opts, context.rawConfig, context.existingDesktop),
|
||||
appPath,
|
||||
shouldWriteAppPath:
|
||||
Boolean(stringValue(opts.appPath)) ||
|
||||
stringValue(context.existingDesktop["appPath"]) !== undefined,
|
||||
shouldSendTest: opts.test !== false,
|
||||
refresh: Boolean(opts.refresh),
|
||||
routingPreset: routingSelection === "preserve" ? undefined : routingSelection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDesktopRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "desktop") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new DesktopSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareDesktopBackend(
|
||||
resolved: ResolvedDesktopSetup,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<DesktopBackend> {
|
||||
if (currentPlatform() !== "darwin") {
|
||||
throw new DesktopSetupError("ao setup desktop is currently only supported on macOS.");
|
||||
}
|
||||
|
||||
if (resolved.backend === "ao-app") {
|
||||
const shouldInstall = !resolved.refresh || force || !isAppInstalled(resolved.appPath);
|
||||
if (shouldInstall) {
|
||||
const appPath = copyBundledApp(resolved.appPath);
|
||||
console.log(chalk.green(`✓ Installed ${APP_NAME} to ${appPath}`));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ ${APP_NAME} is installed at ${resolved.appPath}`));
|
||||
}
|
||||
requestPermission(resolved.appPath);
|
||||
console.log(chalk.green("✓ Notification permission checked"));
|
||||
return "ao-app";
|
||||
}
|
||||
|
||||
if (resolved.backend === "terminal-notifier") {
|
||||
await maybeInstallTerminalNotifier(nonInteractive);
|
||||
console.log(chalk.green("✓ terminal-notifier is available"));
|
||||
return "terminal-notifier";
|
||||
}
|
||||
|
||||
if (resolved.backend === "osascript") {
|
||||
assertOsascriptAvailable();
|
||||
console.log(chalk.green("✓ osascript is available"));
|
||||
return "osascript";
|
||||
}
|
||||
|
||||
const effectiveBackend = resolveAutoBackend(resolved.appPath);
|
||||
if (effectiveBackend === "ao-app") {
|
||||
requestPermission(resolved.appPath);
|
||||
console.log(chalk.green(`✓ auto backend resolved to ${APP_NAME}`));
|
||||
} else if (effectiveBackend === "terminal-notifier") {
|
||||
console.log(chalk.green("✓ auto backend resolved to terminal-notifier"));
|
||||
} else {
|
||||
console.log(chalk.green("✓ auto backend resolved to osascript"));
|
||||
}
|
||||
return effectiveBackend;
|
||||
}
|
||||
|
||||
function sendBackendSetupNotification(
|
||||
effectiveBackend: DesktopBackend,
|
||||
resolved: ResolvedDesktopSetup,
|
||||
): void {
|
||||
try {
|
||||
if (effectiveBackend === "ao-app") {
|
||||
sendAoAppSetupNotification(resolved.appPath, resolved.dashboardUrl);
|
||||
} else if (effectiveBackend === "terminal-notifier") {
|
||||
sendTerminalNotifierSetupNotification(resolved.dashboardUrl);
|
||||
} else if (effectiveBackend === "osascript") {
|
||||
sendOsascriptSetupNotification();
|
||||
}
|
||||
} catch (error) {
|
||||
throw new DesktopSetupError(
|
||||
`Could not send desktop setup test notification: ${formatExecError(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function wireDesktopConfig(
|
||||
configPath: string | undefined,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
resolved: ResolvedDesktopSetup,
|
||||
conflictAlreadyChecked = false,
|
||||
): Promise<boolean> {
|
||||
if (!configPath) {
|
||||
console.log(chalk.dim("No agent-orchestrator.yaml found; skipping config wiring."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
const notifiers = (rawConfig["notifiers"] as Record<string, unknown> | undefined) ?? {};
|
||||
const existingDesktop = (notifiers["desktop"] as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
if (
|
||||
!conflictAlreadyChecked &&
|
||||
!(await shouldReplaceConflictingDesktop(
|
||||
existingDesktop["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desktopConfig: Record<string, unknown> = {
|
||||
...existingDesktop,
|
||||
plugin: "desktop",
|
||||
backend: resolved.backend,
|
||||
};
|
||||
if (resolved.dashboardUrl) desktopConfig["dashboardUrl"] = resolved.dashboardUrl;
|
||||
if (resolved.shouldWriteAppPath) desktopConfig["appPath"] = resolved.appPath;
|
||||
|
||||
notifiers["desktop"] = desktopConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = (rawConfig["defaults"] as Record<string, unknown> | undefined) ?? {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "desktop", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
console.log(chalk.green(`✓ Config written to ${configPath}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function canWireDesktopConfig(
|
||||
configPath: string | undefined,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!configPath) return false;
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
const notifiers = (rawConfig["notifiers"] as Record<string, unknown> | undefined) ?? {};
|
||||
const existingDesktop = (notifiers["desktop"] as Record<string, unknown> | undefined) ?? {};
|
||||
return shouldReplaceConflictingDesktop(existingDesktop["plugin"], force, nonInteractive);
|
||||
}
|
||||
|
||||
function uninstallDesktopApp(): void {
|
||||
const appPath = getInstalledNotifierAppPath();
|
||||
rmSync(appPath, { recursive: true, force: true });
|
||||
console.log(chalk.green(`✓ Removed ${appPath}`));
|
||||
console.log(chalk.dim("AO config was not changed."));
|
||||
}
|
||||
|
||||
export async function runDesktopSetupAction(opts: DesktopSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.uninstall) {
|
||||
uninstallDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const shouldWireConfig = await canWireDesktopConfig(context.configPath, force, nonInteractive);
|
||||
if (context.configPath && !shouldWireConfig) {
|
||||
console.log(chalk.dim("Skipped config wiring."));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await resolveDesktopSetup(opts, context, nonInteractive);
|
||||
const effectiveBackend = await prepareDesktopBackend(resolved, force, nonInteractive);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
sendBackendSetupNotification(effectiveBackend, resolved);
|
||||
console.log(chalk.green("✓ Sent desktop setup test notification"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped desktop setup test notification."));
|
||||
}
|
||||
|
||||
if (shouldWireConfig) {
|
||||
await wireDesktopConfig(context.configPath, force, nonInteractive, resolved, true);
|
||||
} else if (!context.configPath) {
|
||||
console.log(chalk.dim("No agent-orchestrator.yaml found; skipping config wiring."));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped config wiring."));
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Desktop setup complete!")} AO will use ${resolved.backend} for desktop notifications.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to desktop --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Desktop setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to desktop --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,753 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const DISCORD_APP_URL = "https://discord.com/app";
|
||||
const DISCORD_SUPPORT_WEBHOOK_URL =
|
||||
"https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks";
|
||||
const DISCORD_WEBHOOK_DOCS_URL = "https://docs.discord.com/developers/resources/webhook";
|
||||
const DEFAULT_USERNAME = "Agent Orchestrator";
|
||||
const DEFAULT_RETRIES = 2;
|
||||
const DEFAULT_RETRY_DELAY_MS = 1000;
|
||||
const SETUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface DiscordSetupOptions {
|
||||
webhookUrl?: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
threadId?: string;
|
||||
retries?: string;
|
||||
retryDelayMs?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedDiscordSetup {
|
||||
webhookUrl: string;
|
||||
username: string;
|
||||
avatarUrl?: string;
|
||||
threadId?: string;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
shouldSendTest: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class DiscordSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DiscordSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function validateDiscordWebhookUrl(webhookUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(webhookUrl);
|
||||
} catch {
|
||||
throw new DiscordSetupError("Discord webhook URL is invalid.");
|
||||
}
|
||||
|
||||
const validHost = parsed.hostname === "discord.com" || parsed.hostname === "discordapp.com";
|
||||
if (parsed.protocol !== "https:" || !validHost || !parsed.pathname.startsWith("/api/webhooks/")) {
|
||||
throw new DiscordSetupError(
|
||||
"Discord webhook URL must look like https://discord.com/api/webhooks/...",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalHttpUrl(value: string | undefined, label: string): void {
|
||||
if (!value) return;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new DiscordSetupError(`${label} is invalid.`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new DiscordSetupError(`${label} must start with http:// or https://.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: unknown, label: string, fallback: number): number {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new DiscordSetupError(`${label} must be a non-negative integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function existingIntegerText(value: unknown, fallback: number): string {
|
||||
if (value === undefined || value === null || value === "") return String(fallback);
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? String(parsed) : String(fallback);
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new DiscordSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingDiscord(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["discord"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function effectiveWebhookUrl(webhookUrl: string, threadId?: string): string {
|
||||
if (!threadId) return webhookUrl;
|
||||
const separator = webhookUrl.includes("?") ? "&" : "?";
|
||||
return `${webhookUrl}${separator}thread_id=${encodeURIComponent(threadId)}`;
|
||||
}
|
||||
|
||||
function buildSetupPayload(resolved: ResolvedDiscordSetup): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: resolved.username,
|
||||
content: "AO Discord notifications are ready.",
|
||||
embeds: [
|
||||
{
|
||||
title: "AO Discord notifications are ready",
|
||||
description: "This channel is now configured to receive AO notifications.",
|
||||
color: 0x57f287,
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: { text: "Agent Orchestrator" },
|
||||
},
|
||||
],
|
||||
};
|
||||
if (resolved.avatarUrl) payload["avatar_url"] = resolved.avatarUrl;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function sendSetupProbe(resolved: ResolvedDiscordSetup): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
|
||||
const url = effectiveWebhookUrl(resolved.webhookUrl, resolved.threadId);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildSetupPayload(resolved)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 204) return;
|
||||
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new DiscordSetupError(
|
||||
`Discord setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DiscordSetupError) throw error;
|
||||
throw new DiscordSetupError(`Discord setup test failed: ${formatFetchError(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingDiscord(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "discord" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new DiscordSetupError(
|
||||
`notifiers.discord already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.discord already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing Discord notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function printManualWebhookInstructions(): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("Create a Discord incoming webhook"));
|
||||
console.log(` 1. Open ${DISCORD_APP_URL}`);
|
||||
console.log(" 2. Open the target server and channel.");
|
||||
console.log(" 3. Open Edit Channel > Integrations > Webhooks.");
|
||||
console.log(" 4. Create a new webhook.");
|
||||
console.log(" 5. Copy the webhook URL and paste it here.");
|
||||
console.log(chalk.dim(`Discord help: ${DISCORD_SUPPORT_WEBHOOK_URL}`));
|
||||
console.log(chalk.dim(`Developer docs: ${DISCORD_WEBHOOK_DOCS_URL}`));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function explainChannelBinding(): void {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"Discord webhook URLs are bound to the channel selected in Discord. To change channels, create a webhook in that channel.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new DiscordSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function promptDiscordWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const webhookUrlInput = await clack.text({
|
||||
message: "Discord webhook URL:",
|
||||
placeholder: "https://discord.com/api/webhooks/...",
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "Discord webhook URL is required";
|
||||
try {
|
||||
validateDiscordWebhookUrl(String(value));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(webhookUrlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return String(webhookUrlInput);
|
||||
}
|
||||
|
||||
async function promptAfterWebhookInstructions(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
printManualWebhookInstructions();
|
||||
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "After creating the Discord webhook, what do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste webhook URL",
|
||||
hint: "Continue setup",
|
||||
},
|
||||
{
|
||||
value: "show-steps",
|
||||
label: "Show steps again",
|
||||
hint: "Reprint the Discord links and steps",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "show-steps") {
|
||||
printManualWebhookInstructions();
|
||||
continue;
|
||||
}
|
||||
if (next === "enter-url") {
|
||||
return promptDiscordWebhookUrl(clack, initialValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptChangeWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "How do you want to change the Discord webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste new webhook URL",
|
||||
hint: "Use a different Discord webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print Discord steps and wait",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "enter-url") {
|
||||
return promptDiscordWebhookUrl(clack, existingWebhookUrl);
|
||||
}
|
||||
if (next === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: DiscordSetupOptions,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedWebhookUrl = stringValue(opts.webhookUrl);
|
||||
if (providedWebhookUrl) return providedWebhookUrl;
|
||||
|
||||
while (true) {
|
||||
const source = existingWebhookUrl
|
||||
? await clack.select({
|
||||
message: "Discord notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing webhook URL",
|
||||
hint: "Keep sending to the currently configured Discord channel",
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Change webhook URL",
|
||||
hint: "Paste a different Discord webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print Discord steps and wait",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
})
|
||||
: await clack.select({
|
||||
message: "Do you already have a Discord webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "have-url",
|
||||
label: "Yes, I have the URL",
|
||||
hint: "Paste the existing Discord webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "No, show me how to create one",
|
||||
hint: "AO will print Discord steps and wait",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing" && existingWebhookUrl) {
|
||||
return existingWebhookUrl;
|
||||
}
|
||||
|
||||
if (source === "change-url") {
|
||||
const result = await promptChangeWebhookUrl(clack, existingWebhookUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (source === "have-url") {
|
||||
return promptDiscordWebhookUrl(clack, undefined);
|
||||
}
|
||||
|
||||
if (source === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: DiscordSetupOptions,
|
||||
existingDiscord: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedDiscordSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingWebhookUrl = stringValue(existingDiscord["webhookUrl"]);
|
||||
const optionRoutingPreset = resolveDiscordRoutingPreset(opts.routingPreset);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup discord ")));
|
||||
explainChannelBinding();
|
||||
|
||||
while (true) {
|
||||
const resolvedWebhookUrl = await resolveInteractiveWebhookUrl(clack, opts, existingWebhookUrl);
|
||||
|
||||
const usernameInput = await clack.text({
|
||||
message: "Display name AO should request for Discord messages:",
|
||||
placeholder: DEFAULT_USERNAME,
|
||||
initialValue:
|
||||
stringValue(opts.username) ?? stringValue(existingDiscord["username"]) ?? DEFAULT_USERNAME,
|
||||
});
|
||||
|
||||
if (clack.isCancel(usernameInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const avatarUrlInput = await clack.text({
|
||||
message: "Avatar image URL (optional):",
|
||||
placeholder: "https://example.com/avatar.png",
|
||||
initialValue: stringValue(opts.avatarUrl) ?? stringValue(existingDiscord["avatarUrl"]),
|
||||
validate: (value) => {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
validateOptionalHttpUrl(String(value), "Avatar URL");
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(avatarUrlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const threadIdInput = await clack.text({
|
||||
message: "Thread ID (optional; posts into an existing Discord thread):",
|
||||
placeholder: "1234567890",
|
||||
initialValue: stringValue(opts.threadId) ?? stringValue(existingDiscord["threadId"]),
|
||||
});
|
||||
|
||||
if (clack.isCancel(threadIdInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const retriesInput = await clack.text({
|
||||
message: "Retries for rate limits, network errors, and 5xx responses:",
|
||||
placeholder: String(DEFAULT_RETRIES),
|
||||
initialValue:
|
||||
stringValue(opts.retries) ??
|
||||
existingIntegerText(existingDiscord["retries"], DEFAULT_RETRIES),
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseNonNegativeInteger(value, "Retries", DEFAULT_RETRIES);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(retriesInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const retryDelayInput = await clack.text({
|
||||
message: "Base retry delay in milliseconds:",
|
||||
placeholder: String(DEFAULT_RETRY_DELAY_MS),
|
||||
initialValue:
|
||||
stringValue(opts.retryDelayMs) ??
|
||||
existingIntegerText(existingDiscord["retryDelayMs"], DEFAULT_RETRY_DELAY_MS),
|
||||
validate: (value) => {
|
||||
try {
|
||||
parseNonNegativeInteger(value, "Retry delay", DEFAULT_RETRY_DELAY_MS);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(retryDelayInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "discord", "Discord", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return buildResolvedSetup(
|
||||
String(resolvedWebhookUrl),
|
||||
stringValue(usernameInput),
|
||||
stringValue(avatarUrlInput),
|
||||
stringValue(threadIdInput),
|
||||
retriesInput,
|
||||
retryDelayInput,
|
||||
routingSelection === "preserve" ? undefined : routingSelection,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: DiscordSetupOptions,
|
||||
existingDiscord: Record<string, unknown>,
|
||||
): ResolvedDiscordSetup {
|
||||
const webhookUrl =
|
||||
stringValue(opts.webhookUrl) ??
|
||||
(opts.refresh ? stringValue(existingDiscord["webhookUrl"]) : undefined);
|
||||
if (!webhookUrl) {
|
||||
throw new DiscordSetupError(
|
||||
"Discord webhook URL is required. Pass --webhook-url, or run `ao setup discord --refresh` with an existing Discord config.",
|
||||
);
|
||||
}
|
||||
|
||||
return buildResolvedSetup(
|
||||
webhookUrl,
|
||||
stringValue(opts.username) ?? stringValue(existingDiscord["username"]),
|
||||
stringValue(opts.avatarUrl) ?? stringValue(existingDiscord["avatarUrl"]),
|
||||
stringValue(opts.threadId) ?? stringValue(existingDiscord["threadId"]),
|
||||
opts.retries ?? existingDiscord["retries"],
|
||||
opts.retryDelayMs ?? existingDiscord["retryDelayMs"],
|
||||
resolveDiscordRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function buildResolvedSetup(
|
||||
webhookUrl: string,
|
||||
username: string | undefined,
|
||||
avatarUrl: string | undefined,
|
||||
threadId: string | undefined,
|
||||
retriesValue: unknown,
|
||||
retryDelayMsValue: unknown,
|
||||
routingPreset: NotifierRoutingPreset | undefined,
|
||||
opts: DiscordSetupOptions,
|
||||
): ResolvedDiscordSetup {
|
||||
const normalizedWebhookUrl = webhookUrl.trim();
|
||||
validateDiscordWebhookUrl(normalizedWebhookUrl);
|
||||
validateOptionalHttpUrl(avatarUrl, "Avatar URL");
|
||||
|
||||
return {
|
||||
webhookUrl: normalizedWebhookUrl,
|
||||
username: username ?? DEFAULT_USERNAME,
|
||||
avatarUrl,
|
||||
threadId,
|
||||
retries: parseNonNegativeInteger(retriesValue, "Retries", DEFAULT_RETRIES),
|
||||
retryDelayMs: parseNonNegativeInteger(retryDelayMsValue, "Retry delay", DEFAULT_RETRY_DELAY_MS),
|
||||
shouldSendTest: opts.test !== false,
|
||||
routingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscordRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "Discord") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new DiscordSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiscordConfig(configPath: string, resolved: ResolvedDiscordSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existingDiscord = isRecord(notifiers["discord"]) ? notifiers["discord"] : {};
|
||||
const discordConfig: Record<string, unknown> = {
|
||||
...existingDiscord,
|
||||
plugin: "discord",
|
||||
webhookUrl: resolved.webhookUrl,
|
||||
username: resolved.username,
|
||||
retries: resolved.retries,
|
||||
retryDelayMs: resolved.retryDelayMs,
|
||||
};
|
||||
if (resolved.avatarUrl) discordConfig["avatarUrl"] = resolved.avatarUrl;
|
||||
else delete discordConfig["avatarUrl"];
|
||||
if (resolved.threadId) discordConfig["threadId"] = resolved.threadId;
|
||||
else delete discordConfig["threadId"];
|
||||
notifiers["discord"] = discordConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "discord", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
function webhookUrlStatus(webhookUrl: string | undefined): string {
|
||||
if (!webhookUrl) return "not configured";
|
||||
try {
|
||||
return `configured (${new URL(webhookUrl).hostname})`;
|
||||
} catch {
|
||||
return "configured";
|
||||
}
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingDiscord = getExistingDiscord(context.rawConfig);
|
||||
const plugin = stringValue(existingDiscord["plugin"]);
|
||||
const webhookUrl = stringValue(existingDiscord["webhookUrl"]);
|
||||
const username = stringValue(existingDiscord["username"]) ?? DEFAULT_USERNAME;
|
||||
const avatarUrl = stringValue(existingDiscord["avatarUrl"]);
|
||||
const threadId = stringValue(existingDiscord["threadId"]);
|
||||
const retries = parseNonNegativeInteger(existingDiscord["retries"], "Retries", DEFAULT_RETRIES);
|
||||
const retryDelayMs = parseNonNegativeInteger(
|
||||
existingDiscord["retryDelayMs"],
|
||||
"Retry delay",
|
||||
DEFAULT_RETRY_DELAY_MS,
|
||||
);
|
||||
|
||||
console.log(chalk.bold("Discord notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Webhook URL: ${webhookUrlStatus(webhookUrl)}`);
|
||||
console.log(` Username: ${username}`);
|
||||
console.log(` Avatar URL: ${avatarUrl ?? chalk.dim("not configured")}`);
|
||||
console.log(` Thread ID: ${threadId ?? chalk.dim("not configured")}`);
|
||||
console.log(` Retries: ${retries}`);
|
||||
console.log(` Retry delay: ${retryDelayMs}ms`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "discord").label}`);
|
||||
|
||||
if (plugin !== "discord" || !webhookUrl) return;
|
||||
|
||||
try {
|
||||
await sendSetupProbe(
|
||||
buildResolvedSetup(webhookUrl, username, avatarUrl, threadId, retries, retryDelayMs, undefined, {
|
||||
test: true,
|
||||
}),
|
||||
);
|
||||
console.log(chalk.green(" Probe: PASS"));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDiscordSetupAction(opts: DiscordSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingDiscord = getExistingDiscord(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingDiscord(
|
||||
existingDiscord["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingDiscord)
|
||||
: await resolveInteractiveSetup(opts, existingDiscord, context.rawConfig);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
await sendSetupProbe(resolved);
|
||||
console.log(chalk.green("✓ Discord setup test passed"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped Discord setup test."));
|
||||
}
|
||||
|
||||
writeDiscordConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Discord setup complete!")} AO will send notifications through the configured Discord webhook.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to discord --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Discord setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to discord --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import type {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
outro,
|
||||
password,
|
||||
select,
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
|
||||
export const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
export type NotificationPriority = (typeof NOTIFICATION_PRIORITIES)[number];
|
||||
export type NotifierRoutingPreset = "urgent-only" | "urgent-action" | "all";
|
||||
export type NotifierRoutingSelection = NotifierRoutingPreset | "preserve" | "back";
|
||||
|
||||
export interface ClackPrompts {
|
||||
cancel: typeof cancel;
|
||||
confirm: typeof confirm;
|
||||
intro: typeof intro;
|
||||
isCancel: typeof isCancel;
|
||||
log: typeof log;
|
||||
outro: typeof outro;
|
||||
password: typeof password;
|
||||
select: typeof select;
|
||||
spinner: typeof spinner;
|
||||
text: typeof text;
|
||||
}
|
||||
|
||||
const ROUTING_PRESET_PRIORITIES: Record<NotifierRoutingPreset, readonly NotificationPriority[]> = {
|
||||
"urgent-only": ["urgent"],
|
||||
"urgent-action": ["urgent", "action"],
|
||||
all: ["urgent", "action", "warning", "info"],
|
||||
};
|
||||
|
||||
export interface NotifierRoutingState {
|
||||
preset?: NotifierRoutingPreset;
|
||||
priorities: NotificationPriority[];
|
||||
hasRouting: boolean;
|
||||
isCustom: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
if (typeof value === "string") return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
export function isNotifierRoutingPreset(value: string | undefined): value is NotifierRoutingPreset {
|
||||
return value === "urgent-only" || value === "urgent-action" || value === "all";
|
||||
}
|
||||
|
||||
export function parseNotifierRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
return isNotifierRoutingPreset(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function notifierRoutingPresetValues(): string {
|
||||
return "urgent-only | urgent-action | all";
|
||||
}
|
||||
|
||||
export function routingLabel(preset: NotifierRoutingPreset): string {
|
||||
if (preset === "all") return "All priorities";
|
||||
if (preset === "urgent-only") return "Urgent only";
|
||||
return "Urgent + action";
|
||||
}
|
||||
|
||||
export function getNotifierRoutingState(
|
||||
rawConfig: Record<string, unknown>,
|
||||
notifierName: string,
|
||||
): NotifierRoutingState {
|
||||
const routing = isRecord(rawConfig["notificationRouting"])
|
||||
? rawConfig["notificationRouting"]
|
||||
: {};
|
||||
const priorities = NOTIFICATION_PRIORITIES.filter((priority) =>
|
||||
asStringArray(routing[priority]).includes(notifierName),
|
||||
);
|
||||
const hasRouting = priorities.length > 0;
|
||||
|
||||
for (const [preset, presetPriorities] of Object.entries(ROUTING_PRESET_PRIORITIES) as Array<
|
||||
[NotifierRoutingPreset, readonly NotificationPriority[]]
|
||||
>) {
|
||||
if (
|
||||
priorities.length === presetPriorities.length &&
|
||||
presetPriorities.every((priority) => priorities.includes(priority))
|
||||
) {
|
||||
return {
|
||||
preset,
|
||||
priorities,
|
||||
hasRouting,
|
||||
isCustom: false,
|
||||
label: routingLabel(preset),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
priorities,
|
||||
hasRouting,
|
||||
isCustom: hasRouting,
|
||||
label: hasRouting ? priorities.join(" + ") : "not routed",
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureNotifierDefault(rawConfig: Record<string, unknown>, notifierName: string): void {
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
defaults["notifiers"] = unique([
|
||||
...asStringArray(defaults["notifiers"]).filter((value) => value !== notifierName),
|
||||
notifierName,
|
||||
]);
|
||||
rawConfig["defaults"] = defaults;
|
||||
}
|
||||
|
||||
export function applyNotifierRoutingPreset(
|
||||
rawConfig: Record<string, unknown>,
|
||||
notifierName: string,
|
||||
preset: NotifierRoutingPreset | undefined,
|
||||
): void {
|
||||
if (!preset) return;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
const defaultNotifiers = asStringArray(defaults["notifiers"]);
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
const notificationRouting = isRecord(rawConfig["notificationRouting"])
|
||||
? rawConfig["notificationRouting"]
|
||||
: {};
|
||||
const selectedPriorities = new Set<NotificationPriority>(ROUTING_PRESET_PRIORITIES[preset]);
|
||||
|
||||
for (const priority of NOTIFICATION_PRIORITIES) {
|
||||
const base = hasOwn(notificationRouting, priority)
|
||||
? asStringArray(notificationRouting[priority])
|
||||
: defaultNotifiers;
|
||||
const next = base.filter((name) => name !== notifierName);
|
||||
if (selectedPriorities.has(priority)) next.push(notifierName);
|
||||
notificationRouting[priority] = unique(next);
|
||||
}
|
||||
|
||||
rawConfig["notificationRouting"] = notificationRouting;
|
||||
}
|
||||
|
||||
export function resolveRoutingPresetOption(
|
||||
value: string | undefined,
|
||||
label: string,
|
||||
): NotifierRoutingPreset | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = parseNotifierRoutingPreset(value);
|
||||
if (parsed) return parsed;
|
||||
throw new Error(`Invalid ${label} routing preset "${value}". Expected ${notifierRoutingPresetValues()}.`);
|
||||
}
|
||||
|
||||
export async function promptNotifierRoutingPreset(
|
||||
clack: ClackPrompts,
|
||||
rawConfig: Record<string, unknown>,
|
||||
notifierName: string,
|
||||
notifierLabel: string,
|
||||
cancel: () => never,
|
||||
): Promise<NotifierRoutingSelection> {
|
||||
const current = getNotifierRoutingState(rawConfig, notifierName);
|
||||
const choice = await clack.select({
|
||||
message: `Which notifications should ${notifierLabel} receive?`,
|
||||
initialValue: current.preset ?? "urgent-action",
|
||||
options: [
|
||||
...(current.hasRouting
|
||||
? [
|
||||
{
|
||||
value: "preserve",
|
||||
label: `Keep current routing (${current.label})`,
|
||||
hint: "Leave notificationRouting unchanged",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ value: "urgent-action", label: "Urgent + action", hint: "Recommended" },
|
||||
{ value: "urgent-only", label: "Urgent only" },
|
||||
{ value: "all", label: "All priorities" },
|
||||
{ value: "back", label: "Back", hint: "Return to the previous step" },
|
||||
{ value: "cancel", label: "Cancel setup", hint: "Do not change config" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice) || choice === "cancel") {
|
||||
cancel();
|
||||
}
|
||||
|
||||
if (choice === "preserve" || choice === "back") return choice;
|
||||
if (typeof choice === "string" && isNotifierRoutingPreset(choice)) return choice;
|
||||
return "urgent-action";
|
||||
}
|
||||
|
|
@ -0,0 +1,827 @@
|
|||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import {
|
||||
buildCIFailureNotificationData,
|
||||
buildPRStateNotificationData,
|
||||
buildReactionNotificationData,
|
||||
buildSessionTransitionNotificationData,
|
||||
createProjectObserver,
|
||||
recordNotificationDelivery,
|
||||
resolveNotifierTarget,
|
||||
type CICheck,
|
||||
type EventPriority,
|
||||
type EventType,
|
||||
type NotificationDataV3,
|
||||
type NotificationEventContext,
|
||||
type Notifier,
|
||||
type NotifyAction,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorEvent,
|
||||
type PluginRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
export const NOTIFY_TEST_TEMPLATE_NAMES = [
|
||||
"basic",
|
||||
"agent-stuck",
|
||||
"agent-needs-input",
|
||||
"agent-exited",
|
||||
"ci-failing",
|
||||
"review-changes-requested",
|
||||
"approved-and-green",
|
||||
"merge-ready",
|
||||
"all-complete",
|
||||
"pr-closed",
|
||||
] as const;
|
||||
|
||||
export type NotifyTestTemplateName = (typeof NOTIFY_TEST_TEMPLATE_NAMES)[number];
|
||||
|
||||
const VALID_PRIORITIES = ["urgent", "action", "warning", "info"] as const;
|
||||
|
||||
const VALID_EVENT_TYPES = [
|
||||
"session.spawn_started",
|
||||
"session.spawned",
|
||||
"session.working",
|
||||
"session.exited",
|
||||
"session.killed",
|
||||
"session.idle",
|
||||
"session.stuck",
|
||||
"session.needs_input",
|
||||
"session.errored",
|
||||
"pr.created",
|
||||
"pr.updated",
|
||||
"pr.merged",
|
||||
"pr.closed",
|
||||
"ci.passing",
|
||||
"ci.failing",
|
||||
"ci.fix_sent",
|
||||
"ci.fix_failed",
|
||||
"review.pending",
|
||||
"review.approved",
|
||||
"review.changes_requested",
|
||||
"review.comments_sent",
|
||||
"review.comments_unresolved",
|
||||
"automated_review.found",
|
||||
"automated_review.fix_sent",
|
||||
"merge.ready",
|
||||
"merge.conflicts",
|
||||
"merge.completed",
|
||||
"reaction.triggered",
|
||||
"reaction.escalated",
|
||||
"summary.all_complete",
|
||||
] as const satisfies EventType[];
|
||||
|
||||
interface NotifyTemplate {
|
||||
type: EventType;
|
||||
priority: EventPriority;
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
message: string;
|
||||
data: NotificationDataV3;
|
||||
}
|
||||
|
||||
const DEMO_PR_URL = "https://github.com/ComposioHQ/agent-orchestrator/pull/1579";
|
||||
|
||||
const DEMO_PR_CONTEXT: NotificationEventContext = {
|
||||
pr: {
|
||||
number: 1579,
|
||||
url: DEMO_PR_URL,
|
||||
title: "Normalize AO notifier payloads",
|
||||
branch: "ao/demo-notifier-harness",
|
||||
baseBranch: "main",
|
||||
owner: "ComposioHQ",
|
||||
repo: "agent-orchestrator",
|
||||
isDraft: false,
|
||||
},
|
||||
issueId: "AO-1579",
|
||||
issueTitle: "Make AO notification payloads API-grade",
|
||||
summary: "Normalize AO notifier payloads",
|
||||
branch: "ao/demo-notifier-harness",
|
||||
};
|
||||
|
||||
const DEMO_SYSTEM_CONTEXT: NotificationEventContext = {
|
||||
pr: null,
|
||||
issueId: null,
|
||||
issueTitle: null,
|
||||
summary: "AO notification delivery smoke test",
|
||||
branch: null,
|
||||
};
|
||||
|
||||
const DEMO_FAILED_CHECKS: CICheck[] = [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: `${DEMO_PR_URL}/checks?check_run_id=101`,
|
||||
},
|
||||
{
|
||||
name: "unit-tests",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: `${DEMO_PR_URL}/checks?check_run_id=102`,
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_TEMPLATES: Record<NotifyTestTemplateName, NotifyTemplate> = {
|
||||
basic: {
|
||||
type: "summary.all_complete",
|
||||
priority: "info",
|
||||
sessionId: "notify-demo",
|
||||
projectId: "demo",
|
||||
message: "Test notification from ao notify test",
|
||||
data: buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "notify-demo",
|
||||
projectId: "demo",
|
||||
context: DEMO_SYSTEM_CONTEXT,
|
||||
reactionKey: "all-complete",
|
||||
action: "notify",
|
||||
}),
|
||||
},
|
||||
"agent-stuck": {
|
||||
type: "session.stuck",
|
||||
priority: "urgent",
|
||||
sessionId: "demo-agent-7",
|
||||
projectId: "demo",
|
||||
message: "Agent demo-agent-7 appears stuck after repeated inactivity probes",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "session.stuck",
|
||||
sessionId: "demo-agent-7",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "working",
|
||||
newStatus: "stuck",
|
||||
}),
|
||||
},
|
||||
"agent-needs-input": {
|
||||
type: "session.needs_input",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-12",
|
||||
projectId: "demo",
|
||||
message: "Agent demo-agent-12 needs input before it can continue",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "session.needs_input",
|
||||
sessionId: "demo-agent-12",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "working",
|
||||
newStatus: "needs_input",
|
||||
}),
|
||||
},
|
||||
"agent-exited": {
|
||||
type: "session.killed",
|
||||
priority: "urgent",
|
||||
sessionId: "demo-agent-4",
|
||||
projectId: "demo",
|
||||
message: "Agent demo-agent-4 exited before completing its task",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "session.killed",
|
||||
sessionId: "demo-agent-4",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "working",
|
||||
newStatus: "killed",
|
||||
}),
|
||||
},
|
||||
"ci-failing": {
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-19",
|
||||
projectId: "demo",
|
||||
message: "CI is failing on PR #1579",
|
||||
data: buildCIFailureNotificationData({
|
||||
sessionId: "demo-agent-19",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
failedChecks: DEMO_FAILED_CHECKS,
|
||||
}),
|
||||
},
|
||||
"review-changes-requested": {
|
||||
type: "review.changes_requested",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-21",
|
||||
projectId: "demo",
|
||||
message: "Review changes were requested on PR #1579",
|
||||
data: (() => {
|
||||
const data = buildSessionTransitionNotificationData({
|
||||
eventType: "review.changes_requested",
|
||||
sessionId: "demo-agent-21",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "review_pending",
|
||||
newStatus: "changes_requested",
|
||||
});
|
||||
data.review = {
|
||||
...(data.review ?? {}),
|
||||
decision: "changes_requested",
|
||||
unresolvedThreads: 3,
|
||||
url: `${DEMO_PR_URL}#pullrequestreview-1`,
|
||||
};
|
||||
return data;
|
||||
})(),
|
||||
},
|
||||
"approved-and-green": {
|
||||
type: "reaction.triggered",
|
||||
priority: "info",
|
||||
sessionId: "demo-agent-23",
|
||||
projectId: "demo",
|
||||
message: "PR #1579 is approved and CI is green",
|
||||
data: buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "demo-agent-23",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
reactionKey: "approved-and-green",
|
||||
action: "notify",
|
||||
enrichment: {
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
title: "Normalize AO notifier payloads",
|
||||
hasConflicts: false,
|
||||
isBehind: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"merge-ready": {
|
||||
type: "merge.ready",
|
||||
priority: "action",
|
||||
sessionId: "demo-agent-29",
|
||||
projectId: "demo",
|
||||
message: "PR #1579 is ready to merge",
|
||||
data: buildSessionTransitionNotificationData({
|
||||
eventType: "merge.ready",
|
||||
sessionId: "demo-agent-29",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldStatus: "approved",
|
||||
newStatus: "mergeable",
|
||||
enrichment: {
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
title: "Normalize AO notifier payloads",
|
||||
hasConflicts: false,
|
||||
isBehind: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"all-complete": {
|
||||
type: "summary.all_complete",
|
||||
priority: "info",
|
||||
sessionId: "demo-orchestrator",
|
||||
projectId: "demo",
|
||||
message: "All demo sessions completed successfully",
|
||||
data: buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "demo-orchestrator",
|
||||
projectId: "demo",
|
||||
context: DEMO_SYSTEM_CONTEXT,
|
||||
reactionKey: "all-complete",
|
||||
action: "notify",
|
||||
}),
|
||||
},
|
||||
"pr-closed": {
|
||||
type: "pr.closed",
|
||||
priority: "warning",
|
||||
sessionId: "demo-agent-31",
|
||||
projectId: "demo",
|
||||
message: "PR #1579 was closed without merge",
|
||||
data: buildPRStateNotificationData({
|
||||
eventType: "pr.closed",
|
||||
sessionId: "demo-agent-31",
|
||||
projectId: "demo",
|
||||
context: DEMO_PR_CONTEXT,
|
||||
oldPRState: "open",
|
||||
newPRState: "closed",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const NOTIFY_TEST_ACTIONS: NotifyAction[] = [
|
||||
{
|
||||
label: "Open dashboard",
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
{
|
||||
label: "View PR",
|
||||
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
|
||||
},
|
||||
{
|
||||
label: "Acknowledge",
|
||||
callbackEndpoint: "http://localhost:3000/api/notifications/demo/ack",
|
||||
},
|
||||
];
|
||||
|
||||
export interface NotifyTestRequest {
|
||||
templateName?: string;
|
||||
to?: string[];
|
||||
all?: boolean;
|
||||
route?: string;
|
||||
actions?: boolean;
|
||||
message?: string;
|
||||
sessionId?: string;
|
||||
projectId?: string;
|
||||
priority?: string;
|
||||
type?: string;
|
||||
data?: Record<string, unknown>;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface NotifyTestTarget {
|
||||
reference: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export type NotifyDeliveryStatus = "sent" | "dry_run" | "failed" | "unresolved";
|
||||
|
||||
export interface NotifyDeliveryResult {
|
||||
reference: string;
|
||||
pluginName: string;
|
||||
status: NotifyDeliveryStatus;
|
||||
method: "notify" | "notifyWithActions" | null;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NotifyTestResult {
|
||||
ok: boolean;
|
||||
dryRun: boolean;
|
||||
templateName: NotifyTestTemplateName;
|
||||
event: OrchestratorEvent;
|
||||
actions: NotifyAction[];
|
||||
targets: NotifyTestTarget[];
|
||||
deliveries: NotifyDeliveryResult[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface NotifySinkRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
json: unknown;
|
||||
}
|
||||
|
||||
export interface NotifySinkServer {
|
||||
port: number;
|
||||
url: string;
|
||||
requests: NotifySinkRequest[];
|
||||
waitForRequest(timeoutMs?: number): Promise<NotifySinkRequest | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class NotifyTestError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "NotifyTestError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function assertTemplateName(name: string | undefined): NotifyTestTemplateName {
|
||||
const candidate = name ?? "basic";
|
||||
if (isTemplateName(candidate)) return candidate;
|
||||
throw new NotifyTestError(
|
||||
"invalid_template",
|
||||
`Unknown template "${candidate}". Expected one of: ${NOTIFY_TEST_TEMPLATE_NAMES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isTemplateName(value: string): value is NotifyTestTemplateName {
|
||||
return NOTIFY_TEST_TEMPLATE_NAMES.includes(value as NotifyTestTemplateName);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function syncNotificationSubject(
|
||||
data: Record<string, unknown>,
|
||||
sessionId: string,
|
||||
projectId: string,
|
||||
): Record<string, unknown> {
|
||||
if (data.schemaVersion !== 3 || !isRecord(data.subject)) return data;
|
||||
|
||||
return {
|
||||
...data,
|
||||
subject: {
|
||||
...data.subject,
|
||||
session: { id: sessionId, projectId },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertPriority(priority: string, source: string): EventPriority {
|
||||
if ((VALID_PRIORITIES as readonly string[]).includes(priority)) {
|
||||
return priority as EventPriority;
|
||||
}
|
||||
throw new NotifyTestError(
|
||||
"invalid_priority",
|
||||
`Invalid ${source} "${priority}". Expected one of: ${VALID_PRIORITIES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertEventType(type: string): EventType {
|
||||
if ((VALID_EVENT_TYPES as readonly string[]).includes(type)) {
|
||||
return type as EventType;
|
||||
}
|
||||
throw new NotifyTestError(
|
||||
"invalid_event_type",
|
||||
`Invalid event type "${type}". Expected one of: ${VALID_EVENT_TYPES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueRefs(refs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const ref of refs) {
|
||||
const normalized = ref.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function refsFromConfiguredAndDefaults(config: OrchestratorConfig): string[] {
|
||||
return uniqueRefs([
|
||||
...Object.keys(config.notifiers ?? {}),
|
||||
...(config.defaults?.notifiers ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function refsFromAllKnownSources(config: OrchestratorConfig): string[] {
|
||||
return uniqueRefs([
|
||||
...Object.keys(config.notifiers ?? {}),
|
||||
...(config.defaults?.notifiers ?? []),
|
||||
...Object.values(config.notificationRouting ?? {}).flat(),
|
||||
]);
|
||||
}
|
||||
|
||||
function refsFromRoute(config: OrchestratorConfig, priority: EventPriority): string[] {
|
||||
return uniqueRefs(config.notificationRouting?.[priority] ?? config.defaults?.notifiers ?? []);
|
||||
}
|
||||
|
||||
export function createNotifyTestEvent(request: NotifyTestRequest = {}): {
|
||||
templateName: NotifyTestTemplateName;
|
||||
event: OrchestratorEvent;
|
||||
} {
|
||||
const templateName = assertTemplateName(request.templateName);
|
||||
const template = DEMO_TEMPLATES[templateName];
|
||||
const priority = request.priority
|
||||
? assertPriority(request.priority, "priority")
|
||||
: template.priority;
|
||||
const type = request.type ? assertEventType(request.type) : template.type;
|
||||
const sessionId = request.sessionId ?? template.sessionId;
|
||||
const projectId = request.projectId ?? template.projectId;
|
||||
const data = syncNotificationSubject(
|
||||
{
|
||||
...template.data,
|
||||
...(request.data ?? {}),
|
||||
},
|
||||
sessionId,
|
||||
projectId,
|
||||
);
|
||||
|
||||
return {
|
||||
templateName,
|
||||
event: {
|
||||
id: `notify-test-${Date.now()}`,
|
||||
type,
|
||||
priority,
|
||||
sessionId,
|
||||
projectId,
|
||||
timestamp: new Date(),
|
||||
message: request.message ?? template.message,
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNotifyTestTargets(
|
||||
config: OrchestratorConfig,
|
||||
eventPriority: EventPriority,
|
||||
request: NotifyTestRequest = {},
|
||||
): NotifyTestTarget[] {
|
||||
const refs = (() => {
|
||||
if (request.to && request.to.length > 0) {
|
||||
return uniqueRefs(request.to);
|
||||
}
|
||||
if (request.all) {
|
||||
return refsFromAllKnownSources(config);
|
||||
}
|
||||
if (request.route) {
|
||||
return refsFromRoute(config, assertPriority(request.route, "route"));
|
||||
}
|
||||
|
||||
const routedRefs = refsFromRoute(config, eventPriority);
|
||||
return routedRefs.length > 0 ? routedRefs : refsFromConfiguredAndDefaults(config);
|
||||
})();
|
||||
|
||||
return refs.map((ref) => {
|
||||
const target = resolveNotifierTarget(config, ref);
|
||||
return {
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function runNotifyTest(
|
||||
config: OrchestratorConfig,
|
||||
registry: PluginRegistry,
|
||||
request: NotifyTestRequest = {},
|
||||
): Promise<NotifyTestResult> {
|
||||
const { templateName, event } = createNotifyTestEvent(request);
|
||||
const targets = resolveNotifyTestTargets(config, event.priority, request);
|
||||
const actions = request.actions ? [...NOTIFY_TEST_ACTIONS] : [];
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const deliveries: NotifyDeliveryResult[] = [];
|
||||
const observer = request.dryRun ? null : createProjectObserver(config, "notify-test");
|
||||
|
||||
if (targets.length === 0) {
|
||||
errors.push(
|
||||
"No notifier targets resolved. Configure notifiers or pass --to, --all, or --sink.",
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
dryRun: Boolean(request.dryRun),
|
||||
templateName,
|
||||
event,
|
||||
actions,
|
||||
targets,
|
||||
deliveries,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const notifier =
|
||||
registry.get<Notifier>("notifier", target.reference) ??
|
||||
registry.get<Notifier>("notifier", target.pluginName);
|
||||
|
||||
if (!notifier) {
|
||||
const error = `${target.reference}: notifier plugin "${target.pluginName}" is not loaded`;
|
||||
errors.push(error);
|
||||
if (observer) {
|
||||
recordNotificationDelivery({
|
||||
observer,
|
||||
event,
|
||||
target,
|
||||
outcome: "failure",
|
||||
method: "notify",
|
||||
reason: "notifier target not found",
|
||||
failureKind: "target_missing",
|
||||
recordActivityEvent: true,
|
||||
});
|
||||
}
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "unresolved",
|
||||
method: null,
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (request.dryRun) {
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "dry_run",
|
||||
method: actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const method =
|
||||
actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify";
|
||||
if (actions.length > 0 && notifier.notifyWithActions) {
|
||||
await notifier.notifyWithActions(event, actions);
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "sent",
|
||||
method: "notifyWithActions",
|
||||
});
|
||||
} else {
|
||||
const warning =
|
||||
actions.length > 0
|
||||
? `${target.reference}: notifyWithActions() is unavailable; sent with notify()`
|
||||
: undefined;
|
||||
if (warning) warnings.push(warning);
|
||||
|
||||
await notifier.notify(event);
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "sent",
|
||||
method: "notify",
|
||||
warning,
|
||||
});
|
||||
}
|
||||
if (observer) {
|
||||
recordNotificationDelivery({
|
||||
observer,
|
||||
event,
|
||||
target,
|
||||
outcome: "success",
|
||||
method,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = `${target.reference}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
const method =
|
||||
actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify";
|
||||
errors.push(error);
|
||||
if (observer) {
|
||||
recordNotificationDelivery({
|
||||
observer,
|
||||
event,
|
||||
target,
|
||||
outcome: "failure",
|
||||
method,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
failureKind: "delivery_failed",
|
||||
recordActivityEvent: true,
|
||||
});
|
||||
}
|
||||
deliveries.push({
|
||||
reference: target.reference,
|
||||
pluginName: target.pluginName,
|
||||
status: "failed",
|
||||
method,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
dryRun: Boolean(request.dryRun),
|
||||
templateName,
|
||||
event,
|
||||
actions,
|
||||
targets,
|
||||
deliveries,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNotifyDataJson(
|
||||
input: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!input) return undefined;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new NotifyTestError("invalid_json", `Invalid --data JSON: ${message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new NotifyTestError("invalid_json", "--data must be a JSON object");
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseNotifyRefs(input: string | undefined): string[] | undefined {
|
||||
if (!input) return undefined;
|
||||
return uniqueRefs(input.split(","));
|
||||
}
|
||||
|
||||
export function parseSinkPort(input: true | string | undefined): number | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
if (input === true) return 0;
|
||||
|
||||
const port = Number(input);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new NotifyTestError("invalid_sink_port", `Invalid --sink port "${input}"`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export function addSinkNotifierConfig(
|
||||
config: OrchestratorConfig,
|
||||
sinkUrl: string,
|
||||
): OrchestratorConfig {
|
||||
return {
|
||||
...config,
|
||||
defaults: {
|
||||
...config.defaults,
|
||||
notifiers: uniqueRefs(["sink", ...(config.defaults?.notifiers ?? [])]),
|
||||
},
|
||||
notifiers: {
|
||||
...(config.notifiers ?? {}),
|
||||
sink: {
|
||||
plugin: "webhook",
|
||||
url: sinkUrl,
|
||||
retries: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function respond(res: ServerResponse, statusCode: number, body = ""): void {
|
||||
res.statusCode = statusCode;
|
||||
if (body) {
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
}
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export async function startNotifySink(port = 0): Promise<NotifySinkServer> {
|
||||
const requests: NotifySinkRequest[] = [];
|
||||
const waiters: Array<(request: NotifySinkRequest | null) => void> = [];
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
respond(res, 405, "method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readRequestBody(req);
|
||||
const json = (() => {
|
||||
try {
|
||||
return body ? (JSON.parse(body) as unknown) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const request: NotifySinkRequest = {
|
||||
method: req.method,
|
||||
url: req.url ?? "/",
|
||||
headers: req.headers,
|
||||
body,
|
||||
json,
|
||||
};
|
||||
|
||||
requests.push(request);
|
||||
const pending = waiters.splice(0);
|
||||
for (const waiter of pending) waiter(request);
|
||||
respond(res, 204);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
waitForRequest(timeoutMs = 1000): Promise<NotifySinkRequest | null> {
|
||||
if (requests[0]) return Promise.resolve(requests[0]);
|
||||
return new Promise((resolve) => {
|
||||
const waiter = (request: NotifySinkRequest | null) => {
|
||||
clearTimeout(timer);
|
||||
resolve(request);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
},
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
closeServer(server, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function closeServer(server: Server, callback: (err?: Error) => void): void {
|
||||
server.close((err) => callback(err ?? undefined));
|
||||
}
|
||||
|
|
@ -0,0 +1,889 @@
|
|||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
ensureNotifierDefault,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
routingLabel,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
import {
|
||||
DEFAULT_OPENCLAW_URL,
|
||||
HOOKS_PATH,
|
||||
detectOpenClawInstallation,
|
||||
probeGateway,
|
||||
validateToken,
|
||||
} from "./openclaw-probe.js";
|
||||
|
||||
export type OpenClawRoutingPreset = NotifierRoutingPreset;
|
||||
|
||||
export interface OpenClawSetupOptions {
|
||||
url?: string;
|
||||
token?: string;
|
||||
openclawConfigPath?: string;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TokenInfo {
|
||||
value: string;
|
||||
source: "cli" | "env" | "yaml" | "openclaw-config" | "manual";
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
interface ResolvedOpenClawSetup {
|
||||
url: string;
|
||||
token: string;
|
||||
openclawConfigPath: string;
|
||||
routingPreset?: OpenClawRoutingPreset;
|
||||
shouldSendTest: boolean;
|
||||
tokenSource: TokenInfo["source"];
|
||||
}
|
||||
|
||||
const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json");
|
||||
const DISPLAY_OPENCLAW_CONFIG_PATH = "~/.openclaw/openclaw.json";
|
||||
|
||||
export class OpenClawSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OpenClawSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeOpenClawHooksUrl(url: string): string {
|
||||
const normalized = url.trim().replace(/\/+$/, "");
|
||||
return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`;
|
||||
}
|
||||
|
||||
function expandHomePath(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function displayOpenClawConfigPath(path: string): string {
|
||||
const expanded = expandHomePath(path);
|
||||
return expanded === DEFAULT_OPENCLAW_CONFIG_PATH ? DISPLAY_OPENCLAW_CONFIG_PATH : path;
|
||||
}
|
||||
|
||||
function validateOpenClawUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new OpenClawSetupError("OpenClaw webhook URL is invalid.");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new OpenClawSetupError("OpenClaw webhook URL must start with http:// or https://.");
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new OpenClawSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingOpenClaw(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["openclaw"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function getOpenClawJsonPath(): string {
|
||||
return DEFAULT_OPENCLAW_CONFIG_PATH;
|
||||
}
|
||||
|
||||
function readOpenClawJson(configPath: string = DEFAULT_OPENCLAW_CONFIG_PATH): {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
config: Record<string, unknown>;
|
||||
token?: string;
|
||||
} {
|
||||
const path = expandHomePath(configPath);
|
||||
try {
|
||||
if (!existsSync(path)) return { path, exists: false, config: {} };
|
||||
const config = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
const hooks = isRecord(config["hooks"]) ? config["hooks"] : {};
|
||||
return {
|
||||
path,
|
||||
exists: true,
|
||||
config,
|
||||
token: stringValue(hooks["token"]),
|
||||
};
|
||||
} catch {
|
||||
return { path, exists: existsSync(path), config: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredOpenClawConfigPath(
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
): string {
|
||||
return (
|
||||
stringValue(opts.openclawConfigPath) ??
|
||||
stringValue(existingOpenClaw["openclawConfigPath"]) ??
|
||||
stringValue(existingOpenClaw["configPath"]) ??
|
||||
getOpenClawJsonPath()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConfiguredToken(
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
openclawConfigPath: string,
|
||||
): TokenInfo | undefined {
|
||||
const openclawJson = readOpenClawJson(openclawConfigPath);
|
||||
if (openclawJson.token) {
|
||||
return {
|
||||
value: openclawJson.token,
|
||||
source: "openclaw-config",
|
||||
configPath: openclawJson.path,
|
||||
};
|
||||
}
|
||||
|
||||
const rawYamlToken = stringValue(existingOpenClaw["token"]);
|
||||
if (rawYamlToken) {
|
||||
const envVarMatch = rawYamlToken.match(/^\$\{([^}]+)\}$/);
|
||||
if (envVarMatch) {
|
||||
const envValue = process.env[envVarMatch[1]];
|
||||
if (envValue) return { value: envValue, source: "env", configPath: openclawJson.path };
|
||||
} else {
|
||||
return { value: rawYamlToken, source: "yaml", configPath: openclawJson.path };
|
||||
}
|
||||
}
|
||||
|
||||
const envToken = process.env["OPENCLAW_HOOKS_TOKEN"];
|
||||
if (envToken) return { value: envToken, source: "env", configPath: openclawJson.path };
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingOpenClaw(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "openclaw" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new OpenClawSetupError(
|
||||
`notifiers.openclaw already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.openclaw already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing OpenClaw notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new OpenClawSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
function printOpenClawStartInstructions(): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("Start or install OpenClaw"));
|
||||
console.log(" 1. Start your local OpenClaw gateway.");
|
||||
console.log(" 2. Confirm the gateway URL. The default is http://127.0.0.1:18789.");
|
||||
console.log(" 3. Paste the hooks URL here. AO will normalize it to /hooks/agent.");
|
||||
console.log(chalk.dim("If OpenClaw runs elsewhere, use that machine's gateway URL."));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printOpenClawTokenInstructions(openclawConfigPath: string): void {
|
||||
const displayPath = displayOpenClawConfigPath(openclawConfigPath);
|
||||
console.log("");
|
||||
console.log(chalk.bold("Configure the OpenClaw hooks token"));
|
||||
console.log(` 1. Open your OpenClaw config: ${displayPath}`);
|
||||
console.log(" 2. In OpenClaw's webhook/hooks settings, create or copy the hooks token.");
|
||||
console.log(" 3. Put that token in hooks.token and make sure hooks are enabled:");
|
||||
console.log("");
|
||||
console.log(
|
||||
chalk.cyan(`{
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"token": "<openclaw-hooks-token>",
|
||||
"allowRequestSessionKey": true,
|
||||
"allowedSessionKeyPrefixes": ["hook:"]
|
||||
}
|
||||
}`),
|
||||
);
|
||||
console.log("");
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"OpenClaw requires this shared secret for POST /hooks/agent. AO reads it from the OpenClaw config and does not generate or store it in your shell profile.",
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim("Restart OpenClaw after changing the config."));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function promptOpenClawUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const urlInput = await clack.text({
|
||||
message: "OpenClaw webhook URL:",
|
||||
placeholder: `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`,
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "OpenClaw webhook URL is required";
|
||||
try {
|
||||
validateOpenClawUrl(normalizeOpenClawHooksUrl(String(value)));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(urlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return normalizeOpenClawHooksUrl(String(urlInput));
|
||||
}
|
||||
|
||||
async function promptAfterOpenClawInstructions(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
printOpenClawStartInstructions();
|
||||
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "What do you want to do next?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Enter OpenClaw URL",
|
||||
hint: "Paste the local or remote gateway URL",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous menu",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "enter-url") return promptOpenClawUrl(clack, initialValue);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: OpenClawSetupOptions,
|
||||
existingUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedUrl = stringValue(opts.url);
|
||||
if (providedUrl) {
|
||||
const normalized = normalizeOpenClawHooksUrl(providedUrl);
|
||||
validateOpenClawUrl(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const defaultHooksUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`;
|
||||
let detectedUrl: string | undefined;
|
||||
const spin = clack.spinner();
|
||||
spin.start("Detecting OpenClaw gateway on localhost...");
|
||||
const probe = await probeGateway(DEFAULT_OPENCLAW_URL);
|
||||
if (probe.reachable) {
|
||||
detectedUrl = defaultHooksUrl;
|
||||
spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`);
|
||||
} else {
|
||||
spin.stop("No OpenClaw gateway detected on localhost");
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const source = existingUrl
|
||||
? await clack.select({
|
||||
message: "OpenClaw notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing gateway URL",
|
||||
hint: existingUrl,
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Change gateway URL",
|
||||
hint: "Paste a different OpenClaw gateway URL",
|
||||
},
|
||||
{
|
||||
value: "use-local",
|
||||
label: "Use local default URL",
|
||||
hint: defaultHooksUrl,
|
||||
},
|
||||
{
|
||||
value: "need-openclaw",
|
||||
label: "Show OpenClaw setup steps",
|
||||
hint: "AO will print the local gateway requirements",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
})
|
||||
: await clack.select({
|
||||
message: "How do you want to point AO at OpenClaw?",
|
||||
options: [
|
||||
{
|
||||
value: "use-local",
|
||||
label: probe.reachable ? "Use detected local gateway" : "Use local default URL",
|
||||
hint: detectedUrl ?? defaultHooksUrl,
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Enter a different URL",
|
||||
hint: "Use this when OpenClaw runs elsewhere",
|
||||
},
|
||||
{
|
||||
value: "need-openclaw",
|
||||
label: "Show OpenClaw setup steps",
|
||||
hint: "AO will print the local gateway requirements",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing" && existingUrl) return normalizeOpenClawHooksUrl(existingUrl);
|
||||
if (source === "use-local") return detectedUrl ?? defaultHooksUrl;
|
||||
if (source === "change-url") return promptOpenClawUrl(clack, existingUrl ?? detectedUrl);
|
||||
if (source === "need-openclaw") {
|
||||
const result = await promptAfterOpenClawInstructions(clack, existingUrl ?? detectedUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveToken(
|
||||
clack: ClackPrompts,
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
initialOpenClawConfigPath: string,
|
||||
): Promise<TokenInfo | "back"> {
|
||||
const providedToken = stringValue(opts.token);
|
||||
if (providedToken) {
|
||||
return { value: providedToken, source: "cli", configPath: expandHomePath(initialOpenClawConfigPath) };
|
||||
}
|
||||
|
||||
let openclawConfigPath = initialOpenClawConfigPath;
|
||||
while (true) {
|
||||
const existingToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
const options = [
|
||||
...(existingToken
|
||||
? [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: `Use existing token from ${existingToken.source}`,
|
||||
hint:
|
||||
existingToken.source === "openclaw-config"
|
||||
? displayOpenClawConfigPath(existingToken.configPath)
|
||||
: "Legacy fallback; AO will not write shell exports",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: "check-config",
|
||||
label: existingToken ? "Check OpenClaw config again" : "I added hooks.token to OpenClaw config",
|
||||
hint: displayOpenClawConfigPath(openclawConfigPath),
|
||||
},
|
||||
{
|
||||
value: "show-steps",
|
||||
label: "Show where to configure the token",
|
||||
hint: "Print OpenClaw-side config steps",
|
||||
},
|
||||
{
|
||||
value: "manual",
|
||||
label: "Enter token manually",
|
||||
hint: "For remote OpenClaw only; AO stores it in agent-orchestrator.yaml",
|
||||
},
|
||||
{
|
||||
value: "config-path",
|
||||
label: "Use a different OpenClaw config path",
|
||||
hint: "Read hooks.token from another local OpenClaw config",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to gateway URL",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
];
|
||||
|
||||
const choice = await clack.select({
|
||||
message: "How should AO configure the OpenClaw hooks token?",
|
||||
options,
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice) || choice === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (choice === "back") return "back";
|
||||
if (choice === "use-existing" && existingToken) return existingToken;
|
||||
if (choice === "check-config") {
|
||||
const refreshedToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
if (refreshedToken) return refreshedToken;
|
||||
clack.log.warn(
|
||||
`No hooks.token found in ${displayOpenClawConfigPath(openclawConfigPath)} yet.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (choice === "show-steps") {
|
||||
printOpenClawTokenInstructions(openclawConfigPath);
|
||||
continue;
|
||||
}
|
||||
if (choice === "config-path") {
|
||||
const pathInput = await clack.text({
|
||||
message: "OpenClaw config path:",
|
||||
placeholder: DISPLAY_OPENCLAW_CONFIG_PATH,
|
||||
initialValue: displayOpenClawConfigPath(openclawConfigPath),
|
||||
validate: (value) => (!value ? "OpenClaw config path is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(pathInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
openclawConfigPath = String(pathInput);
|
||||
continue;
|
||||
}
|
||||
if (choice === "manual") {
|
||||
const input = await clack.password({
|
||||
message: "OpenClaw hooks token:",
|
||||
validate: (value) => (!value ? "Token is required" : undefined),
|
||||
});
|
||||
if (clack.isCancel(input)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
return { value: String(input), source: "manual", configPath: expandHomePath(openclawConfigPath) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveRoutingPreset(
|
||||
clack: ClackPrompts,
|
||||
opts: OpenClawSetupOptions,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<OpenClawRoutingPreset | undefined | "back"> {
|
||||
const optionPreset = resolveOpenClawRoutingPreset(opts.routingPreset);
|
||||
if (optionPreset) return optionPreset;
|
||||
|
||||
const selection = await promptNotifierRoutingPreset(
|
||||
clack,
|
||||
rawConfig,
|
||||
"openclaw",
|
||||
"OpenClaw",
|
||||
() => cancelSetup(clack),
|
||||
);
|
||||
if (selection === "preserve") return undefined;
|
||||
return selection;
|
||||
}
|
||||
|
||||
function resolveOpenClawRoutingPreset(value: string | undefined): OpenClawRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "OpenClaw") as OpenClawRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new OpenClawSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function printReview(resolved: ResolvedOpenClawSetup): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("OpenClaw setup review"));
|
||||
console.log(` Webhook URL: ${resolved.url}`);
|
||||
console.log(` Token: configured from ${resolved.tokenSource}`);
|
||||
console.log(` OpenClaw config: ${displayOpenClawConfigPath(resolved.openclawConfigPath)}`);
|
||||
console.log(` Routing: ${resolved.routingPreset ? routingLabel(resolved.routingPreset) : "unchanged"}`);
|
||||
console.log(` Setup probe: ${resolved.shouldSendTest ? "enabled" : "skipped"}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function promptInteractiveReview(
|
||||
clack: ClackPrompts,
|
||||
resolved: ResolvedOpenClawSetup,
|
||||
): Promise<"save" | "url" | "token" | "routing"> {
|
||||
printReview(resolved);
|
||||
const choice = await clack.select({
|
||||
message: "Save this OpenClaw setup?",
|
||||
options: [
|
||||
{ value: "save", label: "Save setup" },
|
||||
{ value: "url", label: "Change gateway URL" },
|
||||
{ value: "token", label: "Change token" },
|
||||
{ value: "routing", label: "Change routing" },
|
||||
{ value: "cancel", label: "Cancel setup", hint: "Do not change config" },
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(choice) || choice === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return choice as "save" | "url" | "token" | "routing";
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedOpenClawSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingUrl = stringValue(existingOpenClaw["url"]);
|
||||
const initialOpenClawConfigPath = getConfiguredOpenClawConfigPath(opts, existingOpenClaw);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw ")));
|
||||
|
||||
let url: string | undefined;
|
||||
let tokenInfo: TokenInfo | undefined;
|
||||
let routingPreset: OpenClawRoutingPreset | undefined;
|
||||
let step: "url" | "token" | "routing" | "review" = "url";
|
||||
|
||||
while (true) {
|
||||
if (step === "url") {
|
||||
url = await resolveInteractiveUrl(clack, opts, existingUrl);
|
||||
step = "token";
|
||||
}
|
||||
|
||||
if (step === "token") {
|
||||
const selectedTokenInfo = await resolveInteractiveToken(
|
||||
clack,
|
||||
opts,
|
||||
existingOpenClaw,
|
||||
tokenInfo?.configPath ?? initialOpenClawConfigPath,
|
||||
);
|
||||
if (selectedTokenInfo === "back") {
|
||||
step = "url";
|
||||
continue;
|
||||
}
|
||||
tokenInfo = selectedTokenInfo;
|
||||
step = "routing";
|
||||
}
|
||||
|
||||
if (step === "routing") {
|
||||
const selectedRoutingPreset = await resolveInteractiveRoutingPreset(
|
||||
clack,
|
||||
opts,
|
||||
rawConfig,
|
||||
);
|
||||
if (selectedRoutingPreset === "back") {
|
||||
step = "token";
|
||||
continue;
|
||||
}
|
||||
routingPreset = selectedRoutingPreset;
|
||||
step = "review";
|
||||
}
|
||||
|
||||
if (step === "review") {
|
||||
const resolved = {
|
||||
url: url ?? `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`,
|
||||
token: tokenInfo?.value ?? "",
|
||||
openclawConfigPath: tokenInfo?.configPath ?? expandHomePath(initialOpenClawConfigPath),
|
||||
routingPreset,
|
||||
shouldSendTest: opts.test !== false,
|
||||
tokenSource: tokenInfo?.source ?? "manual",
|
||||
} satisfies ResolvedOpenClawSetup;
|
||||
|
||||
const next = await promptInteractiveReview(clack, resolved);
|
||||
if (next === "save") return resolved;
|
||||
step = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveNonInteractiveSetup(
|
||||
opts: OpenClawSetupOptions,
|
||||
existingOpenClaw: Record<string, unknown>,
|
||||
_rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedOpenClawSetup> {
|
||||
let rawUrl =
|
||||
stringValue(opts.url) ??
|
||||
process.env["OPENCLAW_GATEWAY_URL"] ??
|
||||
(opts.refresh ? stringValue(existingOpenClaw["url"]) : undefined);
|
||||
|
||||
if (!rawUrl) {
|
||||
const installation = await detectOpenClawInstallation();
|
||||
if (installation.state === "running") {
|
||||
rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`;
|
||||
console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`));
|
||||
} else {
|
||||
throw new OpenClawSetupError(
|
||||
"Error: OpenClaw gateway not reachable and no --url provided.\n" +
|
||||
" Start OpenClaw first, or pass --url explicitly:\n" +
|
||||
" Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --openclaw-config-path ~/.openclaw/openclaw.json --non-interactive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const url = normalizeOpenClawHooksUrl(rawUrl);
|
||||
validateOpenClawUrl(url);
|
||||
|
||||
const openclawConfigPath = getConfiguredOpenClawConfigPath(opts, existingOpenClaw);
|
||||
const configuredToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
const tokenInfo =
|
||||
stringValue(opts.token) !== undefined
|
||||
? ({
|
||||
value: stringValue(opts.token) as string,
|
||||
source: "cli",
|
||||
configPath: expandHomePath(openclawConfigPath),
|
||||
} satisfies TokenInfo)
|
||||
: configuredToken;
|
||||
|
||||
if (!tokenInfo) {
|
||||
throw new OpenClawSetupError(
|
||||
`No OpenClaw hooks token found in ${displayOpenClawConfigPath(openclawConfigPath)}.\n` +
|
||||
" Generate or copy the hooks token from OpenClaw, put it in hooks.token, then rerun setup.\n" +
|
||||
` Config example: ${displayOpenClawConfigPath(openclawConfigPath)} -> hooks.token\n` +
|
||||
" For remote OpenClaw only, pass --token explicitly.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(chalk.dim("Skipping setup probe in non-interactive mode. Run `ao setup openclaw --status` to verify."));
|
||||
|
||||
const routingPreset = resolveOpenClawRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "urgent-action");
|
||||
|
||||
return {
|
||||
url,
|
||||
token: tokenInfo.value,
|
||||
openclawConfigPath: tokenInfo.configPath,
|
||||
routingPreset,
|
||||
shouldSendTest: opts.test !== false,
|
||||
tokenSource: tokenInfo.source,
|
||||
};
|
||||
}
|
||||
|
||||
function writeOpenClawConfig(
|
||||
configPath: string,
|
||||
resolved: ResolvedOpenClawSetup,
|
||||
nonInteractive: boolean,
|
||||
): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const openclawConfig: Record<string, unknown> = {
|
||||
plugin: "openclaw",
|
||||
url: resolved.url,
|
||||
openclawConfigPath: displayOpenClawConfigPath(resolved.openclawConfigPath),
|
||||
retries: 3,
|
||||
retryDelayMs: 1000,
|
||||
wakeMode: "now",
|
||||
};
|
||||
if (resolved.tokenSource === "cli" || resolved.tokenSource === "manual" || resolved.tokenSource === "yaml") {
|
||||
openclawConfig["token"] = resolved.token;
|
||||
} else if (resolved.tokenSource === "env") {
|
||||
openclawConfig["token"] = "$" + "{OPENCLAW_HOOKS_TOKEN}";
|
||||
}
|
||||
notifiers["openclaw"] = openclawConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
if (resolved.routingPreset) {
|
||||
ensureNotifierDefault(rawConfig, "openclaw");
|
||||
}
|
||||
applyNotifierRoutingPreset(rawConfig, "openclaw", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log(chalk.green(`✓ Config written to ${configPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
function printOpenClawInstructions(nonInteractive: boolean, resolved: ResolvedOpenClawSetup): void {
|
||||
const tokenLocation =
|
||||
resolved.tokenSource === "openclaw-config"
|
||||
? displayOpenClawConfigPath(resolved.openclawConfigPath)
|
||||
: resolved.tokenSource === "env"
|
||||
? "OPENCLAW_HOOKS_TOKEN"
|
||||
: "agent-orchestrator.yaml";
|
||||
|
||||
if (nonInteractive) {
|
||||
console.log(chalk.green("✓ AO config written (OpenClaw config left unchanged)"));
|
||||
console.log(`Token source: ${tokenLocation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${chalk.green.bold("Done — AO config written.")}`);
|
||||
console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block"));
|
||||
console.log(chalk.dim(` token source — ${tokenLocation}`));
|
||||
if (resolved.tokenSource === "openclaw-config") {
|
||||
console.log(chalk.dim(" AO did not write OpenClaw config or shell profile exports."));
|
||||
}
|
||||
}
|
||||
|
||||
async function runInteractiveSetupProbe(resolved: ResolvedOpenClawSetup): Promise<void> {
|
||||
if (!resolved.shouldSendTest) {
|
||||
console.log(chalk.dim("Skipped OpenClaw setup probe."));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateToken(resolved.url, resolved.token);
|
||||
if (result.valid) {
|
||||
console.log(chalk.green("✓ OpenClaw setup probe passed"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`OpenClaw setup probe did not pass yet: ${result.error ?? "unknown validation error"}`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim("Restart OpenClaw, then run `ao setup openclaw --status` to verify."));
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingOpenClaw = getExistingOpenClaw(context.rawConfig);
|
||||
const plugin = stringValue(existingOpenClaw["plugin"]);
|
||||
const configuredUrl = stringValue(existingOpenClaw["url"]);
|
||||
const url = configuredUrl ? normalizeOpenClawHooksUrl(configuredUrl) : DEFAULT_OPENCLAW_URL;
|
||||
const openclawConfigPath = getConfiguredOpenClawConfigPath({}, existingOpenClaw);
|
||||
const tokenInfo = resolveConfiguredToken(existingOpenClaw, openclawConfigPath);
|
||||
const openclawJson = readOpenClawJson(openclawConfigPath);
|
||||
const installation = await detectOpenClawInstallation(url);
|
||||
|
||||
console.log(chalk.bold("OpenClaw notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Webhook URL: ${configuredUrl ?? chalk.dim("not configured")}`);
|
||||
console.log(` Token: ${tokenInfo ? `configured from ${tokenInfo.source}` : chalk.dim("not configured")}`);
|
||||
console.log(
|
||||
` OpenClaw config: ${openclawJson.exists ? displayOpenClawConfigPath(openclawJson.path) : chalk.dim(`${displayOpenClawConfigPath(openclawJson.path)} not found`)}`,
|
||||
);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "openclaw").label}`);
|
||||
console.log(` Gateway: ${installation.state} at ${installation.gatewayUrl}`);
|
||||
if (installation.binaryPath) console.log(` Binary: ${installation.binaryPath}`);
|
||||
|
||||
if (plugin !== "openclaw" || !tokenInfo || installation.state !== "running") return;
|
||||
|
||||
const validation = await validateToken(url, tokenInfo.value);
|
||||
if (validation.valid) {
|
||||
console.log(chalk.green(" Token probe: PASS"));
|
||||
} else {
|
||||
console.log(chalk.red(` Token probe: FAIL ${validation.error ?? "unknown error"}`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawSetupAction(opts: OpenClawSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingOpenClaw = getExistingOpenClaw(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingOpenClaw(
|
||||
existingOpenClaw["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? await resolveNonInteractiveSetup(opts, existingOpenClaw, context.rawConfig)
|
||||
: await resolveInteractiveSetup(opts, existingOpenClaw, context.rawConfig);
|
||||
|
||||
writeOpenClawConfig(context.configPath, resolved, nonInteractive);
|
||||
|
||||
if (!nonInteractive) {
|
||||
await runInteractiveSetupProbe(resolved);
|
||||
}
|
||||
|
||||
printOpenClawInstructions(nonInteractive, resolved);
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("OpenClaw setup complete!")} AO will send notifications to OpenClaw.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to openclaw --template basic\n") +
|
||||
chalk.dim(" Restart AO with 'ao stop && ao start' to activate."),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ OpenClaw setup complete."));
|
||||
console.log(chalk.dim("Restart AO to activate: ao stop && ao start"));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isTerminalSession,
|
||||
createCorrelationId,
|
||||
createProjectObserver,
|
||||
ConfigNotFoundError,
|
||||
type OrchestratorConfig,
|
||||
type ProjectObserver,
|
||||
} from "@aoagents/ao-core";
|
||||
|
|
@ -24,11 +25,36 @@ interface SupervisorHandle {
|
|||
|
||||
let activeSupervisor: SupervisorHandle | null = null;
|
||||
|
||||
export interface ReconcileProjectSupervisorOptions {
|
||||
intervalMs?: number;
|
||||
type SupervisorConfigSource = "global" | "local-fallback";
|
||||
|
||||
interface LoadedSupervisorConfig {
|
||||
config: OrchestratorConfig;
|
||||
source: SupervisorConfigSource;
|
||||
}
|
||||
|
||||
function isMissingGlobalConfigError(error: unknown): boolean {
|
||||
export interface ReconcileProjectSupervisorOptions {
|
||||
intervalMs?: number;
|
||||
/**
|
||||
* Resolved config path from the caller (typically `ao start`). When the
|
||||
* global config is missing, this is used as the explicit local-fallback
|
||||
* source. Without it the supervisor would fall back to a cwd-walk via
|
||||
* bare `loadConfig()`, which misses configs in `ao start <url>` /
|
||||
* `ao start <path>` first-run flows — there the resolved config can
|
||||
* live under the clone/target path while the daemon's cwd is somewhere
|
||||
* else. A bare cwd-walk in that case throws ConfigNotFoundError, which
|
||||
* `run()` silently swallows, leaving `running.projects` empty.
|
||||
*/
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export interface StartProjectSupervisorOptions {
|
||||
intervalMs?: number;
|
||||
/** See {@link ReconcileProjectSupervisorOptions.configPath}. */
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
function isMissingConfigError(error: unknown): boolean {
|
||||
if (error instanceof ConfigNotFoundError) return true;
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
|
|
@ -38,6 +64,29 @@ function isMissingGlobalConfigError(error: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/** Load the supervisor config: prefer the global registry, fall back to the
|
||||
* caller-resolved local config path (or cwd discovery when none provided).
|
||||
* Returns the source so callers can gate authoritative actions (like the
|
||||
* detach pass) on whether we're looking at the real registry. */
|
||||
function loadSupervisorConfig(configPath?: string): LoadedSupervisorConfig {
|
||||
const globalConfigPath = getGlobalConfigPath();
|
||||
try {
|
||||
return { config: loadConfig(globalConfigPath), source: "global" };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT" &&
|
||||
"path" in error &&
|
||||
error.path === globalConfigPath
|
||||
) {
|
||||
const config = configPath ? loadConfig(configPath) : loadConfig();
|
||||
return { config, source: "local-fallback" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function reportProjectSupervisorError(
|
||||
observer: ProjectObserver,
|
||||
projectId: string,
|
||||
|
|
@ -69,23 +118,31 @@ async function projectHasNonTerminalSession(
|
|||
export async function reconcileProjectSupervisor(
|
||||
options: ReconcileProjectSupervisorOptions = {},
|
||||
): Promise<void> {
|
||||
const config = loadConfig(getGlobalConfigPath());
|
||||
const { config, source } = loadSupervisorConfig(options.configPath);
|
||||
const observer = createProjectObserver(config, "project-supervisor");
|
||||
const configuredProjectIds = new Set(Object.keys(config.projects));
|
||||
const activeProjectIds = new Set(listLifecycleWorkers());
|
||||
|
||||
for (const projectId of activeProjectIds) {
|
||||
if (!configuredProjectIds.has(projectId)) {
|
||||
try {
|
||||
stopLifecycleWorker(projectId);
|
||||
await removeProjectFromRunning(projectId);
|
||||
} catch (error) {
|
||||
reportProjectSupervisorError(
|
||||
observer,
|
||||
projectId,
|
||||
"Failed to detach lifecycle worker for removed project",
|
||||
error,
|
||||
);
|
||||
// Only the authoritative global registry can declare a project "removed".
|
||||
// On a local fallback (e.g. global config was deleted while the daemon is
|
||||
// already supervising multiple projects) the loaded config likely doesn't
|
||||
// enumerate every supervised project — running the detach pass would kill
|
||||
// unrelated lifecycle workers. Pre-fallback behavior was a no-op on
|
||||
// missing global; preserve that property for the detach pass specifically.
|
||||
if (source === "global") {
|
||||
const activeProjectIds = new Set(listLifecycleWorkers());
|
||||
for (const projectId of activeProjectIds) {
|
||||
if (!configuredProjectIds.has(projectId)) {
|
||||
try {
|
||||
stopLifecycleWorker(projectId);
|
||||
await removeProjectFromRunning(projectId);
|
||||
} catch (error) {
|
||||
reportProjectSupervisorError(
|
||||
observer,
|
||||
projectId,
|
||||
"Failed to detach lifecycle worker for removed project",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,16 +174,19 @@ export async function reconcileProjectSupervisor(
|
|||
}
|
||||
|
||||
export async function startProjectSupervisor(
|
||||
intervalMs: number = DEFAULT_SUPERVISOR_INTERVAL_MS,
|
||||
options: StartProjectSupervisorOptions = {},
|
||||
): Promise<SupervisorHandle> {
|
||||
if (activeSupervisor) return activeSupervisor;
|
||||
|
||||
const intervalMs = options.intervalMs ?? DEFAULT_SUPERVISOR_INTERVAL_MS;
|
||||
const configPath = options.configPath;
|
||||
|
||||
let reconciling = false;
|
||||
let pending = false;
|
||||
let stopped = false;
|
||||
let waiters: Array<() => void> = [];
|
||||
|
||||
const run = async (options: { swallowErrors?: boolean } = {}): Promise<void> => {
|
||||
const run = async (runOptions: { swallowErrors?: boolean } = {}): Promise<void> => {
|
||||
if (stopped) return;
|
||||
if (reconciling) {
|
||||
pending = true;
|
||||
|
|
@ -140,10 +200,10 @@ export async function startProjectSupervisor(
|
|||
do {
|
||||
pending = false;
|
||||
try {
|
||||
await reconcileProjectSupervisor({ intervalMs });
|
||||
await reconcileProjectSupervisor({ intervalMs, configPath });
|
||||
} catch (error) {
|
||||
if (isMissingGlobalConfigError(error)) return;
|
||||
if (!options.swallowErrors) throw error;
|
||||
if (isMissingConfigError(error)) return;
|
||||
if (!runOptions.swallowErrors) throw error;
|
||||
// Best-effort background loop: transient config/state errors should not crash ao start.
|
||||
}
|
||||
} while (pending && !stopped);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
isRepoUrl,
|
||||
loadConfig,
|
||||
parseRepoUrl,
|
||||
recordActivityEvent,
|
||||
registerProjectInGlobalConfig,
|
||||
resolveCloneTarget,
|
||||
isRepoAlreadyCloned,
|
||||
|
|
@ -196,6 +197,18 @@ async function fromUrlIntoGlobal(arg: string, deps: ResolveDeps): Promise<Resolv
|
|||
await deps.cloneRepo(parsed, targetDir, cwdDir);
|
||||
console.log(chalk.green(` Cloned to ${targetDir}`));
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_resolve_failed",
|
||||
level: "error",
|
||||
summary: `failed to clone ${parsed.ownerRepo}`,
|
||||
data: {
|
||||
ownerRepo: parsed.ownerRepo,
|
||||
targetDir,
|
||||
source: "url-global",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
|
|
@ -286,6 +299,18 @@ async function fromUrl(arg: string, deps: ResolveDeps, opts: ResolveOptions): Pr
|
|||
spinner.succeed(`Cloned to ${targetDir}`);
|
||||
} catch (err) {
|
||||
spinner.fail("Clone failed");
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.project_resolve_failed",
|
||||
level: "error",
|
||||
summary: `failed to clone ${parsed.ownerRepo}`,
|
||||
data: {
|
||||
ownerRepo: parsed.ownerRepo,
|
||||
targetDir,
|
||||
source: "url-local",
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err },
|
||||
|
|
@ -462,6 +487,13 @@ async function fromCwdOrId(
|
|||
// First run — auto-create config in cwd.
|
||||
config = await deps.autoCreateConfig(cwd());
|
||||
recovered = true;
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.config_recovered",
|
||||
level: "info",
|
||||
summary: `auto-created config in cwd (first-run)`,
|
||||
data: { recovery: "auto_create", cwd: cwd() },
|
||||
});
|
||||
} else {
|
||||
// A config file exists but failed to load — likely a flat local
|
||||
// config whose project isn't in the global registry yet. Recover
|
||||
|
|
@ -469,9 +501,29 @@ async function fromCwdOrId(
|
|||
const foundConfig = findConfigFile() ?? undefined;
|
||||
if (!foundConfig) throw err;
|
||||
const addedId = await deps.registerFlatConfig(foundConfig);
|
||||
if (!addedId) throw err;
|
||||
if (!addedId) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.config_recovery_failed",
|
||||
level: "error",
|
||||
summary: `registerFlatConfig returned null — recovery failed`,
|
||||
data: {
|
||||
configPath: foundConfig,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
config = loadConfig(foundConfig);
|
||||
recovered = true;
|
||||
recordActivityEvent({
|
||||
projectId: addedId,
|
||||
source: "cli",
|
||||
kind: "cli.config_recovered",
|
||||
level: "info",
|
||||
summary: `registered flat config into global config and retried load`,
|
||||
data: { recovery: "register_flat", configPath: foundConfig },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { atomicWriteFileSync } from "@aoagents/ao-core";
|
||||
import { atomicWriteFileSync, recordActivityEvent } from "@aoagents/ao-core";
|
||||
|
||||
export interface RunningState {
|
||||
pid: number;
|
||||
|
|
@ -134,6 +134,18 @@ async function acquireLock(
|
|||
}
|
||||
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.lock_timeout",
|
||||
level: "warn",
|
||||
summary: `lock acquisition timed out`,
|
||||
data: {
|
||||
resourceName,
|
||||
lockFile,
|
||||
timeoutMs,
|
||||
attempts: attempt,
|
||||
},
|
||||
});
|
||||
throw new Error(`Could not acquire ${resourceName} (${lockFile})`);
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +258,18 @@ export async function getRunning(): Promise<RunningState | null> {
|
|||
if (!isProcessAlive(state.pid)) {
|
||||
// Stale entry — process is dead, clean up
|
||||
writeState(null);
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
kind: "cli.stale_running_pruned",
|
||||
level: "warn",
|
||||
summary: `pruned stale running.json entry (dead pid ${state.pid})`,
|
||||
data: {
|
||||
pid: state.pid,
|
||||
port: state.port,
|
||||
startedAt: state.startedAt,
|
||||
projects: state.projects,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
isTerminalSession,
|
||||
loadConfig,
|
||||
markDaemonShutdownHandlerInstalled,
|
||||
recordActivityEvent,
|
||||
sweepDaemonChildren,
|
||||
} from "@aoagents/ao-core";
|
||||
import { stopBunTmpJanitor } from "./bun-tmp-janitor.js";
|
||||
|
|
@ -62,6 +63,15 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
|
||||
const exitCode = signal === "SIGINT" ? 130 : 0;
|
||||
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_signal",
|
||||
level: "info",
|
||||
summary: `received ${signal}, beginning graceful shutdown`,
|
||||
data: { signal, exitCode },
|
||||
});
|
||||
|
||||
try {
|
||||
stopProjectSupervisor();
|
||||
stopAllLifecycleWorkers();
|
||||
|
|
@ -69,7 +79,17 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
// Best-effort — never block shutdown on observability.
|
||||
}
|
||||
|
||||
const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_TIMEOUT_MS);
|
||||
const forceExit = setTimeout(() => {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_force_exit",
|
||||
level: "warn",
|
||||
summary: `force-exit after ${SHUTDOWN_TIMEOUT_MS}ms timeout`,
|
||||
data: { signal, timeoutMs: SHUTDOWN_TIMEOUT_MS, exitCode },
|
||||
});
|
||||
process.exit(exitCode);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
forceExit.unref();
|
||||
|
||||
void (async () => {
|
||||
|
|
@ -86,8 +106,16 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
if (result.cleaned || result.alreadyTerminated) {
|
||||
killedSessionIds.push(session.id);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort per session
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: session.projectId ?? ctx.projectId,
|
||||
sessionId: session.id,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_session_kill_failed",
|
||||
level: "warn",
|
||||
summary: `failed to kill session during shutdown`,
|
||||
data: { errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,18 +135,49 @@ export function installShutdownHandlers(ctx: ShutdownContext): void {
|
|||
for (const [pid, ids] of otherByProject) {
|
||||
otherProjects.push({ projectId: pid, sessionIds: ids });
|
||||
}
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: ctx.projectId,
|
||||
sessionIds: targetIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
try {
|
||||
await writeLastStop({
|
||||
stoppedAt: new Date().toISOString(),
|
||||
projectId: ctx.projectId,
|
||||
sessionIds: targetIds,
|
||||
otherProjects: otherProjects.length > 0 ? otherProjects : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.last_stop_write_failed",
|
||||
level: "error",
|
||||
summary: `failed to write last-stop state during shutdown`,
|
||||
data: {
|
||||
targetSessionCount: targetIds.length,
|
||||
otherProjectCount: otherProjects.length,
|
||||
totalKilled: killedSessionIds.length,
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sweepDaemonChildren({ ownerPid: process.pid });
|
||||
await unregister();
|
||||
} catch {
|
||||
// Best-effort — always exit even if cleanup fails
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_completed",
|
||||
level: "info",
|
||||
summary: `clean shutdown completed`,
|
||||
data: { signal, killedSessionCount: killedSessionIds.length, exitCode },
|
||||
});
|
||||
} catch (err) {
|
||||
recordActivityEvent({
|
||||
projectId: ctx.projectId,
|
||||
source: "cli",
|
||||
kind: "cli.shutdown_failed",
|
||||
level: "error",
|
||||
summary: `shutdown body threw before cleanup completed`,
|
||||
data: { signal, errorMessage: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
try {
|
||||
// Await any in-flight sweep so shutdown does not exit while
|
||||
|
|
|
|||
|
|
@ -0,0 +1,628 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const SLACK_APPS_URL = "https://api.slack.com/apps";
|
||||
const DEFAULT_USERNAME = "Agent Orchestrator";
|
||||
const SETUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface SlackSetupOptions {
|
||||
webhookUrl?: string;
|
||||
channel?: string;
|
||||
username?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedSlackSetup {
|
||||
webhookUrl: string;
|
||||
channel?: string;
|
||||
username: string;
|
||||
shouldSendTest: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class SlackSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "SlackSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function validateSlackWebhookUrl(webhookUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(webhookUrl);
|
||||
} catch {
|
||||
throw new SlackSetupError("Slack webhook URL is invalid.");
|
||||
}
|
||||
|
||||
const validHost =
|
||||
parsed.hostname === "hooks.slack.com" || parsed.hostname === "hooks.slack-gov.com";
|
||||
if (parsed.protocol !== "https:" || !validHost || !parsed.pathname.startsWith("/services/")) {
|
||||
throw new SlackSetupError(
|
||||
"Slack webhook URL must look like https://hooks.slack.com/services/...",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new SlackSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingSlack(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["slack"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function buildSetupPayload(resolved: ResolvedSlackSetup): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: resolved.username,
|
||||
text: "AO Slack notifications are ready.",
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "AO Slack notifications are ready",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: "This channel is now configured to receive AO notifications.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (resolved.channel) payload["channel"] = resolved.channel;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function sendSetupProbe(resolved: ResolvedSlackSetup): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(resolved.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildSetupPayload(resolved)),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new SlackSetupError(
|
||||
`Slack setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SlackSetupError) throw error;
|
||||
throw new SlackSetupError(`Slack setup test failed: ${formatFetchError(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingSlack(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "slack" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new SlackSetupError(
|
||||
`notifiers.slack already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.slack already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing Slack notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function printManualWebhookInstructions(): void {
|
||||
console.log("");
|
||||
console.log(chalk.bold("Create a Slack incoming webhook"));
|
||||
console.log(` 1. Open ${SLACK_APPS_URL}`);
|
||||
console.log(" 2. Create a new app, or select an existing app.");
|
||||
console.log(" 3. Open Incoming Webhooks and activate them.");
|
||||
console.log(" 4. Click Add New Webhook to Workspace.");
|
||||
console.log(" 5. Pick the channel AO should post to and authorize.");
|
||||
console.log(" 6. Copy the generated webhook URL and paste it here.");
|
||||
console.log(
|
||||
chalk.dim("For private channels, the installing Slack user must already be in the channel."),
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function explainChannelBinding(): void {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"Slack incoming webhook URLs are bound to the channel selected during Slack authorization. To change channels, create a webhook for that channel.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new SlackSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function promptSlackWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const webhookUrlInput = await clack.text({
|
||||
message: "Slack incoming webhook URL:",
|
||||
placeholder: "https://hooks.slack.com/services/...",
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "Slack webhook URL is required";
|
||||
try {
|
||||
validateSlackWebhookUrl(String(value));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(webhookUrlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return String(webhookUrlInput);
|
||||
}
|
||||
|
||||
async function promptAfterWebhookInstructions(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
printManualWebhookInstructions();
|
||||
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "After creating the Slack webhook, what do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste webhook URL",
|
||||
hint: "Continue setup",
|
||||
},
|
||||
{
|
||||
value: "show-steps",
|
||||
label: "Show steps again",
|
||||
hint: "Reprint the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "show-steps") {
|
||||
printManualWebhookInstructions();
|
||||
continue;
|
||||
}
|
||||
if (next === "enter-url") {
|
||||
return promptSlackWebhookUrl(clack, initialValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptChangeWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
while (true) {
|
||||
const next = await clack.select({
|
||||
message: "How do you want to change the Slack webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Paste new webhook URL",
|
||||
hint: "Use a different Slack incoming webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
if (next === "enter-url") {
|
||||
return promptSlackWebhookUrl(clack, existingWebhookUrl);
|
||||
}
|
||||
if (next === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: SlackSetupOptions,
|
||||
existingWebhookUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedWebhookUrl = stringValue(opts.webhookUrl);
|
||||
if (providedWebhookUrl) return providedWebhookUrl;
|
||||
|
||||
while (true) {
|
||||
const source = existingWebhookUrl
|
||||
? await clack.select({
|
||||
message: "Slack notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing webhook URL",
|
||||
hint: "Keep sending to the currently configured Slack channel",
|
||||
},
|
||||
{
|
||||
value: "change-url",
|
||||
label: "Change webhook URL",
|
||||
hint: "Paste a different Slack incoming webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "Show me how to create a new webhook",
|
||||
hint: "AO will print the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
})
|
||||
: await clack.select({
|
||||
message: "Do you already have a Slack incoming webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "have-url",
|
||||
label: "Yes, I have the URL",
|
||||
hint: "Paste the existing Slack webhook URL",
|
||||
},
|
||||
{
|
||||
value: "need-url",
|
||||
label: "No, show me how to create one",
|
||||
hint: "AO will print the Slack app URL and steps",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing" && existingWebhookUrl) {
|
||||
return existingWebhookUrl;
|
||||
}
|
||||
|
||||
if (source === "change-url") {
|
||||
const result = await promptChangeWebhookUrl(clack, existingWebhookUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (source === "have-url") {
|
||||
return promptSlackWebhookUrl(clack, undefined);
|
||||
}
|
||||
|
||||
if (source === "need-url") {
|
||||
const result = await promptAfterWebhookInstructions(clack, undefined);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: SlackSetupOptions,
|
||||
existingSlack: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedSlackSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingWebhookUrl = stringValue(existingSlack["webhookUrl"]);
|
||||
const optionRoutingPreset = resolveSlackRoutingPreset(opts.routingPreset);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup slack ")));
|
||||
explainChannelBinding();
|
||||
|
||||
while (true) {
|
||||
const resolvedWebhookUrl = await resolveInteractiveWebhookUrl(clack, opts, existingWebhookUrl);
|
||||
|
||||
const channelInput = await clack.text({
|
||||
message: "Channel name (optional; must match the channel selected when creating the webhook):",
|
||||
placeholder: "#agents",
|
||||
initialValue: stringValue(opts.channel) ?? stringValue(existingSlack["channel"]),
|
||||
});
|
||||
|
||||
if (clack.isCancel(channelInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const usernameInput = await clack.text({
|
||||
message: "Display name (optional):",
|
||||
placeholder: DEFAULT_USERNAME,
|
||||
initialValue:
|
||||
stringValue(opts.username) ?? stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME,
|
||||
});
|
||||
|
||||
if (clack.isCancel(usernameInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "slack", "Slack", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
return buildResolvedSetup(
|
||||
resolvedWebhookUrl,
|
||||
stringValue(channelInput),
|
||||
stringValue(usernameInput),
|
||||
routingSelection === "preserve" ? undefined : routingSelection,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: SlackSetupOptions,
|
||||
existingSlack: Record<string, unknown>,
|
||||
): ResolvedSlackSetup {
|
||||
const webhookUrl =
|
||||
stringValue(opts.webhookUrl) ??
|
||||
(opts.refresh ? stringValue(existingSlack["webhookUrl"]) : undefined);
|
||||
if (!webhookUrl) {
|
||||
throw new SlackSetupError(
|
||||
"Slack webhook URL is required. Pass --webhook-url, or run `ao setup slack --refresh` with an existing Slack config.",
|
||||
);
|
||||
}
|
||||
|
||||
const channel = stringValue(opts.channel) ?? stringValue(existingSlack["channel"]);
|
||||
const username =
|
||||
stringValue(opts.username) ?? stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME;
|
||||
const routingPreset = resolveSlackRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all");
|
||||
return buildResolvedSetup(webhookUrl, channel, username, routingPreset, opts);
|
||||
}
|
||||
|
||||
function buildResolvedSetup(
|
||||
webhookUrl: string,
|
||||
channel: string | undefined,
|
||||
username: string | undefined,
|
||||
routingPreset: NotifierRoutingPreset | undefined,
|
||||
opts: SlackSetupOptions,
|
||||
): ResolvedSlackSetup {
|
||||
const normalizedWebhookUrl = webhookUrl.trim();
|
||||
validateSlackWebhookUrl(normalizedWebhookUrl);
|
||||
return {
|
||||
webhookUrl: normalizedWebhookUrl,
|
||||
channel,
|
||||
username: username ?? DEFAULT_USERNAME,
|
||||
shouldSendTest: opts.test !== false,
|
||||
routingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSlackRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "Slack") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new SlackSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function writeSlackConfig(configPath: string, resolved: ResolvedSlackSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existingSlack = isRecord(notifiers["slack"]) ? notifiers["slack"] : {};
|
||||
const slackConfig: Record<string, unknown> = {
|
||||
...existingSlack,
|
||||
plugin: "slack",
|
||||
webhookUrl: resolved.webhookUrl,
|
||||
username: resolved.username,
|
||||
};
|
||||
if (resolved.channel) slackConfig["channel"] = resolved.channel;
|
||||
else delete slackConfig["channel"];
|
||||
notifiers["slack"] = slackConfig;
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "slack", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
function webhookUrlStatus(webhookUrl: string | undefined): string {
|
||||
if (!webhookUrl) return "not configured";
|
||||
try {
|
||||
return `configured (${new URL(webhookUrl).hostname})`;
|
||||
} catch {
|
||||
return "configured";
|
||||
}
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingSlack = getExistingSlack(context.rawConfig);
|
||||
const plugin = stringValue(existingSlack["plugin"]);
|
||||
const webhookUrl = stringValue(existingSlack["webhookUrl"]);
|
||||
const channel = stringValue(existingSlack["channel"]);
|
||||
const username = stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME;
|
||||
|
||||
console.log(chalk.bold("Slack notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` Webhook URL: ${webhookUrlStatus(webhookUrl)}`);
|
||||
console.log(` Channel: ${channel ?? chalk.dim("webhook default")}`);
|
||||
console.log(` Username: ${username}`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "slack").label}`);
|
||||
|
||||
if (plugin !== "slack" || !webhookUrl) return;
|
||||
|
||||
try {
|
||||
await sendSetupProbe(buildResolvedSetup(webhookUrl, channel, username, undefined, { test: true }));
|
||||
console.log(chalk.green(" Probe: PASS"));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSlackSetupAction(opts: SlackSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingSlack = getExistingSlack(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingSlack(
|
||||
existingSlack["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingSlack)
|
||||
: await resolveInteractiveSetup(opts, existingSlack, context.rawConfig);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
await sendSetupProbe(resolved);
|
||||
console.log(chalk.green("✓ Slack setup test passed"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped Slack setup test."));
|
||||
}
|
||||
|
||||
writeSlackConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Slack setup complete!")} AO will send notifications through the configured Slack webhook.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to slack --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Slack setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to slack --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import chalk from "chalk";
|
||||
import { parseDocument } from "yaml";
|
||||
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
|
||||
import {
|
||||
applyNotifierRoutingPreset,
|
||||
getNotifierRoutingState,
|
||||
promptNotifierRoutingPreset,
|
||||
resolveRoutingPresetOption,
|
||||
type ClackPrompts,
|
||||
type NotifierRoutingPreset,
|
||||
} from "./notifier-routing.js";
|
||||
|
||||
const DEFAULT_RETRIES = 2;
|
||||
const DEFAULT_RETRY_DELAY_MS = 1000;
|
||||
const SETUP_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface WebhookSetupOptions {
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
refresh?: boolean;
|
||||
status?: boolean;
|
||||
test?: boolean;
|
||||
force?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
routingPreset?: string;
|
||||
}
|
||||
|
||||
interface WebhookConfig {
|
||||
plugin: "webhook";
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
}
|
||||
|
||||
interface ConfigContext {
|
||||
configPath: string;
|
||||
rawConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResolvedWebhookSetup {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
shouldSendTest: boolean;
|
||||
routingPreset?: NotifierRoutingPreset;
|
||||
}
|
||||
|
||||
export class WebhookSetupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode = 1,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WebhookSetupError";
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function validateWebhookUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new WebhookSetupError("Webhook URL is invalid.");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new WebhookSetupError("Webhook URL must start with http:// or https://.");
|
||||
}
|
||||
}
|
||||
|
||||
function readConfigContext(): ConfigContext {
|
||||
const configPath = findConfigFile() ?? undefined;
|
||||
if (!configPath) {
|
||||
throw new WebhookSetupError(
|
||||
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
return { configPath, rawConfig };
|
||||
}
|
||||
|
||||
function getExistingWebhook(rawConfig: Record<string, unknown>): Record<string, unknown> {
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
const existing = notifiers["webhook"];
|
||||
return isRecord(existing) ? existing : {};
|
||||
}
|
||||
|
||||
function getBearerToken(headers: unknown): string | undefined {
|
||||
if (!isRecord(headers)) return undefined;
|
||||
const authorization = stringValue(headers["Authorization"] ?? headers["authorization"]);
|
||||
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function formatFetchError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function buildSetupPayload(): Record<string, unknown> {
|
||||
return {
|
||||
type: "notification",
|
||||
event: {
|
||||
id: `webhook-setup-${Date.now()}`,
|
||||
type: "setup.webhook",
|
||||
priority: "info",
|
||||
sessionId: "setup",
|
||||
projectId: "ao",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "AO webhook notifications are ready.",
|
||||
data: {
|
||||
source: "ao-setup-webhook",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendSetupProbe(resolved: ResolvedWebhookSetup): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(resolved.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(resolved.headers ?? {}),
|
||||
},
|
||||
body: JSON.stringify(buildSetupPayload()),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new WebhookSetupError(
|
||||
`Webhook setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookSetupError) throw error;
|
||||
throw new WebhookSetupError(`Webhook setup test failed: ${formatFetchError(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldReplaceConflictingWebhook(
|
||||
existingPlugin: unknown,
|
||||
force: boolean,
|
||||
nonInteractive: boolean,
|
||||
): Promise<boolean> {
|
||||
if (existingPlugin === undefined || existingPlugin === "webhook" || force) return true;
|
||||
if (nonInteractive) {
|
||||
throw new WebhookSetupError(
|
||||
`notifiers.webhook already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const clack = await import("@clack/prompts");
|
||||
const replace = await clack.confirm({
|
||||
message: `notifiers.webhook already uses plugin "${String(existingPlugin)}". Replace it?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (clack.isCancel(replace) || !replace) {
|
||||
console.log(chalk.dim("Keeping existing webhook notifier config."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelSetup(clack: ClackPrompts): never {
|
||||
clack.cancel("Setup cancelled.");
|
||||
throw new WebhookSetupError("Setup cancelled.", 0);
|
||||
}
|
||||
|
||||
async function promptWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
initialValue: string | undefined,
|
||||
): Promise<string> {
|
||||
const urlInput = await clack.text({
|
||||
message: "Webhook URL:",
|
||||
placeholder: "https://example.com/ao-events",
|
||||
initialValue,
|
||||
validate: (value) => {
|
||||
if (!value) return "URL is required";
|
||||
try {
|
||||
validateWebhookUrl(String(value));
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (clack.isCancel(urlInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
return String(urlInput);
|
||||
}
|
||||
|
||||
async function promptChangeWebhookUrl(
|
||||
clack: ClackPrompts,
|
||||
existingUrl: string | undefined,
|
||||
): Promise<string | "back"> {
|
||||
const next = await clack.select({
|
||||
message: "How do you want to configure the webhook URL?",
|
||||
options: [
|
||||
{
|
||||
value: "enter-url",
|
||||
label: "Add new webhook URL",
|
||||
hint: "Paste a different HTTP endpoint",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Back",
|
||||
hint: "Return to the previous options",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(next) || next === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (next === "back") return "back";
|
||||
return promptWebhookUrl(clack, existingUrl);
|
||||
}
|
||||
|
||||
async function resolveInteractiveUrl(
|
||||
clack: ClackPrompts,
|
||||
opts: WebhookSetupOptions,
|
||||
existingUrl: string | undefined,
|
||||
): Promise<string> {
|
||||
const providedUrl = stringValue(opts.url);
|
||||
if (providedUrl) return providedUrl;
|
||||
|
||||
while (true) {
|
||||
if (!existingUrl) {
|
||||
return promptWebhookUrl(clack, undefined);
|
||||
}
|
||||
|
||||
const source = await clack.select({
|
||||
message: "Webhook notifier is already configured. What do you want to do?",
|
||||
options: [
|
||||
{
|
||||
value: "use-existing",
|
||||
label: "Use existing webhook URL",
|
||||
hint: "Keep sending to the currently configured endpoint",
|
||||
},
|
||||
{
|
||||
value: "add-new",
|
||||
label: "Add new webhook URL",
|
||||
hint: "Paste a different HTTP endpoint",
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "Cancel setup",
|
||||
hint: "Do not change config",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (clack.isCancel(source) || source === "cancel") {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (source === "use-existing") {
|
||||
return existingUrl;
|
||||
}
|
||||
|
||||
if (source === "add-new") {
|
||||
const result = await promptChangeWebhookUrl(clack, existingUrl);
|
||||
if (result === "back") continue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInteractiveSetup(
|
||||
opts: WebhookSetupOptions,
|
||||
existingWebhook: Record<string, unknown>,
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Promise<ResolvedWebhookSetup> {
|
||||
const clack = await import("@clack/prompts");
|
||||
const existingUrl = stringValue(existingWebhook["url"]);
|
||||
const existingToken = getBearerToken(existingWebhook["headers"]);
|
||||
const optionRoutingPreset = resolveWebhookRoutingPreset(opts.routingPreset);
|
||||
|
||||
clack.intro(chalk.bgCyan(chalk.black(" ao setup webhook ")));
|
||||
|
||||
while (true) {
|
||||
const resolvedUrl = await resolveInteractiveUrl(clack, opts, existingUrl);
|
||||
|
||||
let authToken = stringValue(opts.authToken);
|
||||
if (!authToken && existingToken) {
|
||||
const keepExisting = await clack.confirm({
|
||||
message: "Keep the existing Authorization bearer token?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (clack.isCancel(keepExisting)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
if (keepExisting) authToken = existingToken;
|
||||
}
|
||||
|
||||
if (!authToken) {
|
||||
const tokenInput = await clack.password({
|
||||
message: "Auth token (optional; leave blank for none):",
|
||||
});
|
||||
|
||||
if (clack.isCancel(tokenInput)) {
|
||||
cancelSetup(clack);
|
||||
}
|
||||
|
||||
authToken = stringValue(tokenInput);
|
||||
}
|
||||
|
||||
const routingSelection =
|
||||
optionRoutingPreset ??
|
||||
(await promptNotifierRoutingPreset(clack, rawConfig, "webhook", "webhook", () =>
|
||||
cancelSetup(clack),
|
||||
));
|
||||
if (routingSelection === "back") continue;
|
||||
|
||||
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
|
||||
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
|
||||
|
||||
return buildResolvedSetup(
|
||||
resolvedUrl,
|
||||
authToken,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
routingSelection === "preserve" ? undefined : routingSelection,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNonInteractiveSetup(
|
||||
opts: WebhookSetupOptions,
|
||||
existingWebhook: Record<string, unknown>,
|
||||
): ResolvedWebhookSetup {
|
||||
const url =
|
||||
stringValue(opts.url) ?? (opts.refresh ? stringValue(existingWebhook["url"]) : undefined);
|
||||
if (!url) {
|
||||
throw new WebhookSetupError(
|
||||
"Webhook URL is required. Pass --url, or run `ao setup webhook --refresh` with an existing webhook config.",
|
||||
);
|
||||
}
|
||||
|
||||
const authToken = stringValue(opts.authToken) ?? getBearerToken(existingWebhook["headers"]);
|
||||
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
|
||||
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
|
||||
const routingPreset =
|
||||
resolveWebhookRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all");
|
||||
|
||||
return buildResolvedSetup(url, authToken, retries, retryDelayMs, routingPreset, opts);
|
||||
}
|
||||
|
||||
function buildResolvedSetup(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
retries: number,
|
||||
retryDelayMs: number,
|
||||
routingPreset: NotifierRoutingPreset | undefined,
|
||||
opts: WebhookSetupOptions,
|
||||
): ResolvedWebhookSetup {
|
||||
const normalizedUrl = url.trim();
|
||||
validateWebhookUrl(normalizedUrl);
|
||||
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined;
|
||||
return {
|
||||
url: normalizedUrl,
|
||||
headers,
|
||||
retries,
|
||||
retryDelayMs,
|
||||
shouldSendTest: opts.test !== false,
|
||||
routingPreset,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWebhookRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
|
||||
try {
|
||||
return resolveRoutingPresetOption(value, "webhook") as NotifierRoutingPreset | undefined;
|
||||
} catch (error) {
|
||||
throw new WebhookSetupError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function toWebhookConfig(resolved: ResolvedWebhookSetup): WebhookConfig {
|
||||
return {
|
||||
plugin: "webhook",
|
||||
url: resolved.url,
|
||||
...(resolved.headers ? { headers: resolved.headers } : {}),
|
||||
retries: resolved.retries,
|
||||
retryDelayMs: resolved.retryDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
function writeWebhookConfig(configPath: string, resolved: ResolvedWebhookSetup): void {
|
||||
const rawYaml = readFileSync(configPath, "utf-8");
|
||||
const doc = parseDocument(rawYaml);
|
||||
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
|
||||
|
||||
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
|
||||
notifiers["webhook"] = toWebhookConfig(resolved);
|
||||
rawConfig["notifiers"] = notifiers;
|
||||
|
||||
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
|
||||
rawConfig["defaults"] = defaults;
|
||||
|
||||
applyNotifierRoutingPreset(rawConfig, "webhook", resolved.routingPreset);
|
||||
|
||||
if (!isCanonicalGlobalConfigPath(configPath)) {
|
||||
const currentSchema = doc.get("$schema");
|
||||
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
|
||||
doc.set("$schema", CONFIG_SCHEMA_URL);
|
||||
}
|
||||
}
|
||||
|
||||
doc.setIn(["notifiers"], rawConfig["notifiers"]);
|
||||
doc.setIn(["defaults"], rawConfig["defaults"]);
|
||||
if (rawConfig["notificationRouting"] !== undefined) {
|
||||
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
|
||||
}
|
||||
writeFileSync(configPath, doc.toString({ indent: 2 }));
|
||||
}
|
||||
|
||||
async function printStatus(): Promise<void> {
|
||||
const context = readConfigContext();
|
||||
const existingWebhook = getExistingWebhook(context.rawConfig);
|
||||
const plugin = stringValue(existingWebhook["plugin"]);
|
||||
const url = stringValue(existingWebhook["url"]);
|
||||
const hasAuth = Boolean(getBearerToken(existingWebhook["headers"]));
|
||||
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
|
||||
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
|
||||
|
||||
console.log(chalk.bold("Webhook notifier status"));
|
||||
console.log(` Config: ${context.configPath}`);
|
||||
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
|
||||
console.log(` URL: ${url ?? chalk.dim("not configured")}`);
|
||||
console.log(` Auth: ${hasAuth ? "Authorization bearer token configured" : "none"}`);
|
||||
console.log(` Retries: ${retries}`);
|
||||
console.log(` Retry delay: ${retryDelayMs}ms`);
|
||||
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "webhook").label}`);
|
||||
|
||||
if (plugin !== "webhook" || !url) return;
|
||||
|
||||
try {
|
||||
await sendSetupProbe(
|
||||
buildResolvedSetup(
|
||||
url,
|
||||
getBearerToken(existingWebhook["headers"]),
|
||||
retries,
|
||||
retryDelayMs,
|
||||
undefined,
|
||||
{ test: true },
|
||||
),
|
||||
);
|
||||
console.log(chalk.green(" Probe: PASS"));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWebhookSetupAction(opts: WebhookSetupOptions): Promise<void> {
|
||||
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
|
||||
const force = Boolean(opts.force);
|
||||
|
||||
if (opts.status) {
|
||||
await printStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readConfigContext();
|
||||
const existingWebhook = getExistingWebhook(context.rawConfig);
|
||||
const shouldWire = await shouldReplaceConflictingWebhook(
|
||||
existingWebhook["plugin"],
|
||||
force,
|
||||
nonInteractive,
|
||||
);
|
||||
if (!shouldWire) return;
|
||||
|
||||
const resolved = nonInteractive
|
||||
? resolveNonInteractiveSetup(opts, existingWebhook)
|
||||
: await resolveInteractiveSetup(opts, existingWebhook, context.rawConfig);
|
||||
|
||||
if (resolved.shouldSendTest) {
|
||||
await sendSetupProbe(resolved);
|
||||
console.log(chalk.green("✓ Webhook setup test passed"));
|
||||
} else {
|
||||
console.log(chalk.dim("Skipped webhook setup test."));
|
||||
}
|
||||
|
||||
writeWebhookConfig(context.configPath, resolved);
|
||||
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
|
||||
|
||||
if (!nonInteractive) {
|
||||
const clack = await import("@clack/prompts");
|
||||
clack.outro(
|
||||
`${chalk.green("Webhook setup complete!")} AO will send notifications to ${resolved.url}.\n` +
|
||||
chalk.dim(" Test it with: ao notify test --to webhook --template basic"),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.green("\n✓ Webhook setup complete."));
|
||||
console.log(chalk.dim("Test it with: ao notify test --to webhook --template basic"));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { registerSession } from "./commands/session.js";
|
|||
import { registerSend } from "./commands/send.js";
|
||||
import { registerAcknowledge, registerReport } from "./commands/report.js";
|
||||
import { registerReviewCheck } from "./commands/review-check.js";
|
||||
import { registerReview } from "./commands/review.js";
|
||||
import { registerDashboard } from "./commands/dashboard.js";
|
||||
import { registerOpen } from "./commands/open.js";
|
||||
import { registerStart, registerStop } from "./commands/start.js";
|
||||
|
|
@ -13,6 +14,7 @@ import { registerDoctor } from "./commands/doctor.js";
|
|||
import { registerUpdate } from "./commands/update.js";
|
||||
import { registerSetup } from "./commands/setup.js";
|
||||
import { registerPlugin } from "./commands/plugin.js";
|
||||
import { registerNotify } from "./commands/notify.js";
|
||||
import { registerProjectCommand } from "./commands/project.js";
|
||||
import { registerMigrateStorage } from "./commands/migrate-storage.js";
|
||||
import { registerCompletion } from "./commands/completion.js";
|
||||
|
|
@ -39,6 +41,7 @@ export function createProgram(): Command {
|
|||
registerAcknowledge(program);
|
||||
registerReport(program);
|
||||
registerReviewCheck(program);
|
||||
registerReview(program);
|
||||
registerDashboard(program);
|
||||
registerOpen(program);
|
||||
registerVerify(program);
|
||||
|
|
@ -46,6 +49,7 @@ export function createProgram(): Command {
|
|||
registerUpdate(program);
|
||||
registerSetup(program);
|
||||
registerPlugin(program);
|
||||
registerNotify(program);
|
||||
registerProjectCommand(program);
|
||||
registerMigrateStorage(program);
|
||||
registerCompletion(program);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
"zod": "^3.24.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^11.0.0"
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "^12.3.0",
|
||||
|
|
@ -99,8 +99,8 @@
|
|||
"@types/node": "^25.2.3",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"rollup": "^4.60.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { applyAgentReport } from "../agent-report.js";
|
||||
import { writeMetadata, writeCanonicalLifecycle } from "../metadata.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-agent-report-events-${randomUUID()}`);
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
sessionId = "ao-1";
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("activity events: agent-report", () => {
|
||||
it("emits api.agent_report.transition_rejected when the transition is invalid", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "terminated";
|
||||
writeMetadata(dataDir, sessionId, {
|
||||
worktree: "/tmp/worktree",
|
||||
branch: "feat/x",
|
||||
status: "terminated",
|
||||
project: "demo",
|
||||
});
|
||||
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
|
||||
|
||||
expect(() =>
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "working",
|
||||
now: new Date(),
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const rejected = calls.find(
|
||||
(c) => c.kind === "api.agent_report.transition_rejected",
|
||||
);
|
||||
expect(rejected).toBeDefined();
|
||||
expect(rejected?.sessionId).toBe(sessionId);
|
||||
expect(rejected?.source).toBe("api");
|
||||
});
|
||||
|
||||
it("does not emit transition_rejected on a valid transition", () => {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker");
|
||||
lifecycle.session.state = "working";
|
||||
lifecycle.session.reason = "task_in_progress";
|
||||
lifecycle.session.startedAt = "2024-12-01T00:00:00.000Z";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
writeMetadata(dataDir, sessionId, {
|
||||
worktree: "/tmp/worktree",
|
||||
branch: "feat/x",
|
||||
status: "working",
|
||||
project: "demo",
|
||||
});
|
||||
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
|
||||
|
||||
applyAgentReport(dataDir, sessionId, {
|
||||
state: "needs_input",
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "api.agent_report.transition_rejected")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import { saveGlobalConfig, type GlobalConfig } from "../global-config.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeGlobalConfig(projects: GlobalConfig["projects"] = {}): GlobalConfig {
|
||||
return {
|
||||
port: 3000,
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects,
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("activity events: config loading", () => {
|
||||
let tempRoot: string;
|
||||
let configPath: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalGlobalConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = join(
|
||||
tmpdir(),
|
||||
`ao-config-events-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
configPath = join(tempRoot, ".agent-orchestrator", "config.yaml");
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
originalHome = process.env["HOME"];
|
||||
originalGlobalConfig = process.env["AO_GLOBAL_CONFIG"];
|
||||
process.env["HOME"] = tempRoot;
|
||||
process.env["AO_GLOBAL_CONFIG"] = configPath;
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env["HOME"] = originalHome;
|
||||
if (originalGlobalConfig === undefined) {
|
||||
delete process.env["AO_GLOBAL_CONFIG"];
|
||||
} else {
|
||||
process.env["AO_GLOBAL_CONFIG"] = originalGlobalConfig;
|
||||
}
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits config.project_resolve_failed for unresolved projects without a specific config event", () => {
|
||||
const projectPath = join(tempRoot, "old-format");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectPath, "agent-orchestrator.yaml"),
|
||||
["projects:", " old-format:", " path: .", ""].join("\n"),
|
||||
);
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
"old-format": {
|
||||
projectId: "old-format",
|
||||
path: projectPath,
|
||||
displayName: "Old format",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "old-format",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_resolve_failed",
|
||||
projectId: "old-format",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit config.project_resolve_failed for malformed local yaml", () => {
|
||||
const projectPath = join(tempRoot, "malformed");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "tracker: [\n");
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
malformed: {
|
||||
projectId: "malformed",
|
||||
path: projectPath,
|
||||
displayName: "Malformed",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "malformed",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_malformed",
|
||||
projectId: "malformed",
|
||||
}),
|
||||
);
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit config.project_resolve_failed for healthy projects", () => {
|
||||
const projectPath = join(tempRoot, "clean");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectPath, "agent-orchestrator.yaml"),
|
||||
["agent: codex", "runtime: tmux", "workspace: worktree", ""].join("\n"),
|
||||
);
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
clean: {
|
||||
projectId: "clean",
|
||||
path: projectPath,
|
||||
displayName: "Clean",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "clean",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits config.project_malformed for unparseable local yaml", () => {
|
||||
const projectPath = join(tempRoot, "malformed");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "tracker: [\n");
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
malformed: {
|
||||
projectId: "malformed",
|
||||
path: projectPath,
|
||||
displayName: "Malformed",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "malformed",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_malformed",
|
||||
projectId: "malformed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits config.project_invalid for schema-invalid local yaml", () => {
|
||||
const projectPath = join(tempRoot, "invalid");
|
||||
mkdirSync(projectPath, { recursive: true });
|
||||
// Valid YAML but fails LocalProjectConfigSchema (numeric agent isn't a string)
|
||||
writeFileSync(join(projectPath, "agent-orchestrator.yaml"), "agent: 123\n");
|
||||
|
||||
saveGlobalConfig(
|
||||
makeGlobalConfig({
|
||||
invalid: {
|
||||
projectId: "invalid",
|
||||
path: projectPath,
|
||||
displayName: "Invalid",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "invalid",
|
||||
},
|
||||
}),
|
||||
configPath,
|
||||
);
|
||||
|
||||
loadConfig(configPath);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "config",
|
||||
kind: "config.project_invalid",
|
||||
projectId: "invalid",
|
||||
}),
|
||||
);
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "config.project_resolve_failed")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { migrateStorage } from "../migration/storage-v2.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
`ao-migration-events-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("activity events: storage migration", () => {
|
||||
let testDir: string;
|
||||
let aoBaseDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = createTempDir();
|
||||
aoBaseDir = join(testDir, ".agent-orchestrator");
|
||||
configPath = join(aoBaseDir, "config.yaml");
|
||||
mkdirSync(aoBaseDir, { recursive: true });
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
vi.mocked(execSync).mockReturnValue("");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("emits migration.completed when there is nothing to migrate", async () => {
|
||||
const result = await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(result.projects).toBe(0);
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const completed = calls.find((c) => c.kind === "migration.completed");
|
||||
expect(completed).toBeDefined();
|
||||
expect(completed?.source).toBe("migration");
|
||||
expect(completed?.level).toBe("info");
|
||||
expect(completed?.data).toMatchObject({
|
||||
projectsMigrated: 0,
|
||||
sessions: 0,
|
||||
worktrees: 0,
|
||||
projectErrors: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits migration.completed with totals when migration succeeds", async () => {
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
mkdirSync(join(hashDir, "worktrees", "ao-1"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(hashDir, "sessions", "ao-1"),
|
||||
[
|
||||
"project=myproject",
|
||||
"agent=claude-code",
|
||||
"status=working",
|
||||
"createdAt=2026-04-21T12:00:00.000Z",
|
||||
"branch=session/ao-1",
|
||||
`worktree=${join(hashDir, "worktrees", "ao-1")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"projects:",
|
||||
" myproject:",
|
||||
" path: /home/user/myproject",
|
||||
" storageKey: aaaaaa000000",
|
||||
" defaultBranch: main",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const completed = calls.find((c) => c.kind === "migration.completed");
|
||||
expect(completed).toBeDefined();
|
||||
const data = (completed?.data ?? {}) as Record<string, unknown>;
|
||||
expect(data["projectsMigrated"]).toBe(1);
|
||||
expect(data["projectErrors"]).toBe(0);
|
||||
});
|
||||
|
||||
it("emits migration.project_failed plus migration.completed when a project fails", async () => {
|
||||
// Force migrateProject to throw by pre-creating projects/badproj as a FILE
|
||||
// — mkdirSync with recursive:true tolerates existing directories but NOT existing
|
||||
// files at the same path, so it throws EEXIST when migrateProject tries to create
|
||||
// projects/badproj/sessions.
|
||||
const badDir = join(aoBaseDir, "bbbbbb000000-badproj");
|
||||
mkdirSync(join(badDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(badDir, "sessions", "ao-99"),
|
||||
[
|
||||
"project=badproj",
|
||||
"agent=claude-code",
|
||||
"createdAt=2026-04-21T12:00:00.000Z",
|
||||
"branch=session/ao-99",
|
||||
`worktree=${join(badDir, "worktrees", "ao-99")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
// Pre-create projects/badproj as a regular file so mkdirSync inside migrateProject
|
||||
// raises ENOTDIR / EEXIST when it tries to create a subdirectory under it.
|
||||
mkdirSync(join(aoBaseDir, "projects"), { recursive: true });
|
||||
writeFileSync(join(aoBaseDir, "projects", "badproj"), "blocker");
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"projects:",
|
||||
" badproj:",
|
||||
" path: /home/user/badproj",
|
||||
" storageKey: bbbbbb000000",
|
||||
" defaultBranch: main",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const projectFailed = calls.filter((c) => c.kind === "migration.project_failed");
|
||||
const completed = calls.find((c) => c.kind === "migration.completed");
|
||||
|
||||
expect(projectFailed.length).toBeGreaterThanOrEqual(1);
|
||||
expect(projectFailed[0]?.projectId).toBe("badproj");
|
||||
expect(completed).toBeDefined();
|
||||
const completedData = (completed?.data ?? {}) as Record<string, unknown>;
|
||||
expect(completedData["projectErrors"]).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("emits migration.blocked when active sessions are detected", async () => {
|
||||
vi.mocked(execSync).mockReturnValue("ao-1\nabcdef012345-worker-7\nunrelated\n");
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
"projects:\n myproject:\n path: /tmp/x\n storageKey: aaaaaa000000\n defaultBranch: main\n",
|
||||
);
|
||||
|
||||
await expect(migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
log: () => {},
|
||||
})).rejects.toThrow(/Found 2 active AO tmux session/);
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const blocked = calls.find((c) => c.kind === "migration.blocked");
|
||||
expect(blocked).toBeDefined();
|
||||
expect(blocked?.source).toBe("migration");
|
||||
expect(blocked?.level).toBe("warn");
|
||||
expect(blocked?.summary).toBe("migration blocked by 2 active session(s)");
|
||||
expect(blocked?.data).toMatchObject({
|
||||
activeSessionCount: 2,
|
||||
sample: ["ao-1", "abcdef012345-worker-7"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit migration.blocked when force skips active-session detection", async () => {
|
||||
vi.mocked(execSync).mockReturnValue("ao-1\n");
|
||||
|
||||
const hashDir = join(aoBaseDir, "aaaaaa000000-myproject");
|
||||
mkdirSync(join(hashDir, "sessions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(hashDir, "sessions", "ao-1"),
|
||||
[
|
||||
"project=myproject",
|
||||
"agent=claude-code",
|
||||
"createdAt=2026-04-21T12:00:00.000Z",
|
||||
"branch=session/ao-1",
|
||||
`worktree=${join(hashDir, "worktrees", "ao-1")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
mkdirSync(join(hashDir, "worktrees", "ao-1"), { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
"projects:\n myproject:\n path: /tmp/x\n storageKey: aaaaaa000000\n defaultBranch: main\n",
|
||||
);
|
||||
|
||||
// force: true skips the active-session check, so no migration.blocked event.
|
||||
await migrateStorage({
|
||||
aoBaseDir,
|
||||
globalConfigPath: configPath,
|
||||
force: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
expect(calls.find((c) => c.kind === "migration.blocked")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRegistry } from "../plugin-registry.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import type { OrchestratorConfig, PluginManifest, PluginModule } from "../types.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
function makePlugin(slot: PluginManifest["slot"], name: string): PluginModule {
|
||||
return {
|
||||
manifest: {
|
||||
name,
|
||||
slot,
|
||||
description: `Test ${slot} plugin: ${name}`,
|
||||
version: "0.0.1",
|
||||
},
|
||||
create: vi.fn(() => ({ name })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeOrchestratorConfig(overrides?: Partial<OrchestratorConfig>): OrchestratorConfig {
|
||||
return {
|
||||
projects: {},
|
||||
...overrides,
|
||||
} as OrchestratorConfig;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
describe("activity events: plugin-registry", () => {
|
||||
it("emits plugin-registry.load_failed when a configured external plugin fails to import", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const config = makeOrchestratorConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "broken-plugin",
|
||||
source: "npm",
|
||||
package: "@example/broken",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await registry.loadFromConfig(config, async (pkg: string) => {
|
||||
// Built-ins all silently skip; external import throws
|
||||
if (pkg === "@example/broken") {
|
||||
throw new Error("module not found");
|
||||
}
|
||||
throw new Error(`builtin not installed: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const loadFailed = calls.find(
|
||||
(c) =>
|
||||
c.kind === "plugin-registry.load_failed" &&
|
||||
c.source === "plugin-registry" &&
|
||||
(c.data as Record<string, unknown> | undefined)?.["builtin"] === false,
|
||||
);
|
||||
expect(loadFailed).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits plugin-registry.specifier_failed when a plugin specifier cannot be resolved", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const config = makeOrchestratorConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: "no-specifier",
|
||||
source: "local",
|
||||
// No path — resolvePluginSpecifier returns null
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await registry.loadFromConfig(config, async (pkg: string) => {
|
||||
throw new Error(`builtin not installed: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const specifierFailed = calls.find(
|
||||
(c) => c.kind === "plugin-registry.specifier_failed",
|
||||
);
|
||||
expect(specifierFailed).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits plugin-registry.load_failed when a built-in plugin's register() throws", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
// Make a plugin whose create() throws to force registration failure
|
||||
const fakeRuntime: PluginModule = {
|
||||
manifest: {
|
||||
name: "tmux",
|
||||
slot: "runtime",
|
||||
description: "throwing test plugin",
|
||||
version: "0.0.1",
|
||||
},
|
||||
create: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-runtime-tmux") return fakeRuntime;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const loadFailed = calls.find((c) => c.kind === "plugin-registry.load_failed");
|
||||
expect(loadFailed).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not emit any failure events when plugins load cleanly", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakePlugin = makePlugin("runtime", "tmux");
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-runtime-tmux") return fakePlugin;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
const calls = vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0]);
|
||||
const failures = calls.filter((c) =>
|
||||
typeof c.kind === "string" && c.kind.startsWith("plugin-registry."),
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,9 +9,26 @@ import {
|
|||
appendActivityEntry,
|
||||
recordTerminalActivity,
|
||||
getActivityLogPath,
|
||||
ACTIVITY_INPUT_STALENESS_MS,
|
||||
getActivityFallbackState,
|
||||
} from "../activity-log.js";
|
||||
import type { ActivityState } from "../types.js";
|
||||
import type { ActivityDetection, ActivityLogEntry, ActivityState } from "../types.js";
|
||||
|
||||
const minutesAgo = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
|
||||
const toActivityResult = (
|
||||
entry: ActivityLogEntry,
|
||||
): { entry: ActivityLogEntry; modifiedAt: Date } => ({
|
||||
entry,
|
||||
modifiedAt: new Date(entry.ts),
|
||||
});
|
||||
|
||||
const detectWithProcessCheck = (
|
||||
isProcessRunning: boolean,
|
||||
activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null,
|
||||
): ActivityDetection | null => {
|
||||
if (!isProcessRunning) return { state: "exited", timestamp: new Date() };
|
||||
return checkActivityLogState(activityResult) ?? getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
|
||||
};
|
||||
|
||||
describe("classifyTerminalActivity", () => {
|
||||
it("returns active state with no trigger", () => {
|
||||
|
|
@ -56,13 +73,20 @@ describe("checkActivityLogState", () => {
|
|||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns null for stale waiting_input entry", () => {
|
||||
const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString();
|
||||
it("returns waiting_input even when older than the former wallclock cap", () => {
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: staleTs, state: "waiting_input", source: "terminal" },
|
||||
entry: { ts: minutesAgo(10), state: "waiting_input", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked even when older than the former wallclock cap", () => {
|
||||
const result = checkActivityLogState({
|
||||
entry: { ts: minutesAgo(6), state: "blocked", source: "terminal" },
|
||||
modifiedAt: new Date(),
|
||||
});
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns null for non-critical states", () => {
|
||||
|
|
@ -82,6 +106,77 @@ describe("checkActivityLogState", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getActivityFallbackState", () => {
|
||||
it("returns waiting_input for a 10-minute-old entry instead of decaying to idle", () => {
|
||||
const result = getActivityFallbackState(
|
||||
toActivityResult({ ts: minutesAgo(10), state: "waiting_input", source: "terminal" }),
|
||||
30_000,
|
||||
5 * 60_000,
|
||||
);
|
||||
|
||||
expect(result?.state).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns blocked for a 6-minute-old entry instead of decaying to idle", () => {
|
||||
const result = getActivityFallbackState(
|
||||
toActivityResult({ ts: minutesAgo(6), state: "blocked", source: "terminal" }),
|
||||
30_000,
|
||||
5 * 60_000,
|
||||
);
|
||||
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("returns blocked for a 1-minute-old entry with unchanged behavior", () => {
|
||||
const result = getActivityFallbackState(
|
||||
toActivityResult({ ts: minutesAgo(1), state: "blocked", source: "terminal" }),
|
||||
30_000,
|
||||
5 * 60_000,
|
||||
);
|
||||
|
||||
expect(result?.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("lets a newer active entry override an older waiting_input entry", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "ao-test-"));
|
||||
try {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
const waitingEntry: ActivityLogEntry = {
|
||||
ts: minutesAgo(6),
|
||||
state: "waiting_input",
|
||||
source: "terminal",
|
||||
};
|
||||
const activeEntry: ActivityLogEntry = {
|
||||
ts: new Date(Date.now() - 1000).toISOString(),
|
||||
state: "active",
|
||||
source: "terminal",
|
||||
};
|
||||
await writeFile(
|
||||
getActivityLogPath(tmpDir),
|
||||
`${JSON.stringify(waitingEntry)}\n${JSON.stringify(activeEntry)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const activityResult = await readLastActivityEntry(tmpDir);
|
||||
const result = getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
|
||||
|
||||
expect(activityResult?.entry.state).toBe("active");
|
||||
expect(result?.state).toBe("active");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns exited when the process check fails before a stale waiting_input can fall through", () => {
|
||||
const result = detectWithProcessCheck(
|
||||
false,
|
||||
toActivityResult({ ts: minutesAgo(6), state: "waiting_input", source: "terminal" }),
|
||||
);
|
||||
|
||||
expect(result?.state).toBe("exited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readLastActivityEntry", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
|
|
@ -144,6 +239,25 @@ describe("readLastActivityEntry", () => {
|
|||
const result = await readLastActivityEntry(tmpDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the previous complete line when a read races a truncated tail", async () => {
|
||||
await mkdir(join(tmpDir, ".ao"), { recursive: true });
|
||||
const completeEntry: ActivityLogEntry = {
|
||||
ts: minutesAgo(10),
|
||||
state: "waiting_input",
|
||||
source: "terminal",
|
||||
trigger: "approve?",
|
||||
};
|
||||
await writeFile(
|
||||
getActivityLogPath(tmpDir),
|
||||
`${JSON.stringify(completeEntry)}\n{"ts":"${new Date().toISOString()}","state":`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readLastActivityEntry(tmpDir);
|
||||
|
||||
expect(result?.entry).toEqual(completeEntry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordTerminalActivity", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -18,17 +18,25 @@ import {
|
|||
} from "../agent-report.js";
|
||||
import { writeMetadata, writeCanonicalLifecycle, readMetadataRaw } from "../metadata.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import type { CanonicalSessionLifecycle } from "../types.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`);
|
||||
tempRoot = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`);
|
||||
dataDir = join(tempRoot, "projects", "project-alpha", "sessions");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedWorkerSession(
|
||||
|
|
@ -433,6 +441,13 @@ describe("applyAgentReport", () => {
|
|||
sessionState: "terminated",
|
||||
},
|
||||
});
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-alpha",
|
||||
sessionId,
|
||||
kind: "api.agent_report.transition_rejected",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the session does not exist", () => {
|
||||
|
|
@ -442,6 +457,13 @@ describe("applyAgentReport", () => {
|
|||
now: new Date(),
|
||||
}),
|
||||
).toThrow(/not found/);
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "project-alpha",
|
||||
sessionId: "missing-session",
|
||||
kind: "api.agent_report.session_not_found",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// 260 atomic-write cycles are slow on Windows (rename + AV scan); bump the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,738 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createActivitySignal } from "../activity-signal.js";
|
||||
import { createCodeReviewStore, type CodeReviewStore } from "../code-review-store.js";
|
||||
import {
|
||||
buildCodexCodeReviewArgs,
|
||||
CodeReviewNoOpenFindingsError,
|
||||
executeCodeReviewRun,
|
||||
markOutdatedCodeReviewRunsForSession,
|
||||
parseReviewerOutput,
|
||||
prepareGitReviewerWorkspace,
|
||||
sendCodeReviewFindingsToAgent,
|
||||
triggerCodeReviewForSession,
|
||||
} from "../code-review-manager.js";
|
||||
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
|
||||
import {
|
||||
SessionNotFoundError,
|
||||
type OrchestratorConfig,
|
||||
type Session,
|
||||
type SessionManager,
|
||||
} from "../types.js";
|
||||
|
||||
let storeDir: string;
|
||||
let store: CodeReviewStore;
|
||||
|
||||
const config: OrchestratorConfig = {
|
||||
configPath: "/tmp/ao/agent-orchestrator.yaml",
|
||||
readyThresholdMs: 300_000,
|
||||
defaults: { runtime: "tmux", agent: "codex", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
app: {
|
||||
name: "App",
|
||||
path: "/tmp/app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
reactions: {},
|
||||
};
|
||||
|
||||
function makeSession(overrides: Partial<Session> & { id?: string } = {}): Session {
|
||||
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2026-05-10T10:00:00.000Z"));
|
||||
lifecycle.session.state = "idle";
|
||||
lifecycle.session.reason = "awaiting_external_review";
|
||||
lifecycle.pr.state = "open";
|
||||
lifecycle.pr.reason = "review_pending";
|
||||
lifecycle.pr.number = 7;
|
||||
lifecycle.pr.url = "https://github.com/acme/app/pull/7";
|
||||
lifecycle.runtime.state = "alive";
|
||||
lifecycle.runtime.reason = "process_running";
|
||||
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
activitySignal: createActivitySignal("valid", {
|
||||
activity: "idle",
|
||||
timestamp: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "native",
|
||||
}),
|
||||
lifecycle,
|
||||
branch: "feat/todos",
|
||||
issueId: null,
|
||||
pr: {
|
||||
number: 7,
|
||||
url: "https://github.com/acme/app/pull/7",
|
||||
title: "feat: todos",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/todos",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
workspacePath: "/tmp/app-worktree",
|
||||
runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date("2026-05-10T09:00:00.000Z"),
|
||||
lastActivityAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSessionManager(
|
||||
session: Session | null,
|
||||
overrides: Partial<SessionManager> = {},
|
||||
): SessionManager {
|
||||
const manager: SessionManager = {
|
||||
get: async (sessionId: string) => (session?.id === sessionId ? session : null),
|
||||
list: async () => (session ? [session] : []),
|
||||
spawn: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
spawnOrchestrator: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
ensureOrchestrator: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
relaunchOrchestrator: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
restore: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
kill: async () => ({ cleaned: false, alreadyTerminated: false }),
|
||||
cleanup: async () => ({ killed: [], skipped: [], errors: [] }),
|
||||
send: async () => {},
|
||||
claimPR: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
};
|
||||
|
||||
return Object.assign(manager, overrides);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeDir = join(tmpdir(), `ao-test-code-review-manager-${randomUUID()}`);
|
||||
mkdirSync(storeDir, { recursive: true });
|
||||
store = createCodeReviewStore("app", { storeDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("triggerCodeReviewForSession", () => {
|
||||
it("creates a queued review run linked to the worker session", async () => {
|
||||
const session = makeSession();
|
||||
const run = await triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => "abc123",
|
||||
now: new Date("2026-05-10T11:00:00.000Z"),
|
||||
},
|
||||
{ sessionId: "app-1", requestedBy: "cli" },
|
||||
);
|
||||
|
||||
expect(run).toMatchObject({
|
||||
projectId: "app",
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
prNumber: 7,
|
||||
prUrl: "https://github.com/acme/app/pull/7",
|
||||
summary: "Review requested from CLI for app-1.",
|
||||
findingCount: 0,
|
||||
});
|
||||
expect(store.listRuns()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allocates reviewer ids from all prior project review runs", async () => {
|
||||
store.createRun({ linkedSessionId: "app-1", reviewerSessionId: "app-rev-1" });
|
||||
store.createRun({ linkedSessionId: "app-2", reviewerSessionId: "app-rev-7" });
|
||||
|
||||
const run = await triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession({ id: "app-3" })),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => undefined,
|
||||
},
|
||||
{ sessionId: "app-3", requestedBy: "web" },
|
||||
);
|
||||
|
||||
expect(run.reviewerSessionId).toBe("app-rev-8");
|
||||
});
|
||||
|
||||
it("allocates unique reviewer ids for concurrent review requests", async () => {
|
||||
let resolveGate: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
resolveGate = resolve;
|
||||
});
|
||||
let shaLookups = 0;
|
||||
const options = {
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => {
|
||||
shaLookups++;
|
||||
if (shaLookups === 2) {
|
||||
resolveGate?.();
|
||||
}
|
||||
await gate;
|
||||
return "abc123";
|
||||
},
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
triggerCodeReviewForSession(options, { sessionId: "app-1", requestedBy: "web" }),
|
||||
triggerCodeReviewForSession(options, { sessionId: "app-1", requestedBy: "web" }),
|
||||
]);
|
||||
|
||||
expect(new Set([first.reviewerSessionId, second.reviewerSessionId]).size).toBe(2);
|
||||
expect(
|
||||
store
|
||||
.listRuns()
|
||||
.map((run) => run.reviewerSessionId)
|
||||
.sort(),
|
||||
).toEqual(["app-rev-1", "app-rev-2"]);
|
||||
});
|
||||
|
||||
it("marks previous review runs for older worker SHAs as outdated", async () => {
|
||||
const oldTriage = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const oldClean = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "clean",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const failed = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-3",
|
||||
status: "failed",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const otherWorker = store.createRun({
|
||||
linkedSessionId: "app-2",
|
||||
reviewerSessionId: "app-rev-4",
|
||||
status: "needs_triage",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
|
||||
const run = await triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
resolveTargetSha: async () => "new-sha",
|
||||
now: new Date("2026-05-10T12:00:00.000Z"),
|
||||
},
|
||||
{ sessionId: "app-1", requestedBy: "web" },
|
||||
);
|
||||
|
||||
expect(run.reviewerSessionId).toBe("app-rev-5");
|
||||
expect(store.getRun(oldTriage.id)?.status).toBe("outdated");
|
||||
expect(store.getRun(oldClean.id)?.status).toBe("outdated");
|
||||
expect(store.getRun(failed.id)?.status).toBe("failed");
|
||||
expect(store.getRun(otherWorker.id)?.status).toBe("needs_triage");
|
||||
});
|
||||
|
||||
it("marks stale review runs outdated when the worker HEAD changes", async () => {
|
||||
const oldWaiting = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "waiting_update",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
const currentQueued = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "queued",
|
||||
targetSha: "new-sha",
|
||||
});
|
||||
const failed = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-3",
|
||||
status: "failed",
|
||||
targetSha: "old-sha",
|
||||
});
|
||||
|
||||
const updatedCount = await markOutdatedCodeReviewRunsForSession({
|
||||
store,
|
||||
session: makeSession(),
|
||||
resolveTargetSha: async () => "new-sha",
|
||||
now: new Date("2026-05-10T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(updatedCount).toBe(1);
|
||||
expect(store.getRun(oldWaiting.id)?.status).toBe("outdated");
|
||||
expect(store.getRun(currentQueued.id)?.status).toBe("queued");
|
||||
expect(store.getRun(failed.id)?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("rejects missing and orchestrator sessions", async () => {
|
||||
await expect(
|
||||
triggerCodeReviewForSession(
|
||||
{ config, sessionManager: makeSessionManager(null), storeFactory: () => store },
|
||||
{ sessionId: "app-404" },
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||
|
||||
await expect(
|
||||
triggerCodeReviewForSession(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(
|
||||
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
|
||||
),
|
||||
storeFactory: () => store,
|
||||
},
|
||||
{ sessionId: "app-orchestrator" },
|
||||
),
|
||||
).rejects.toThrow(/Cannot request code review for orchestrator session/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeCodeReviewRun", () => {
|
||||
it("runs a reviewer in an isolated workspace and persists findings", async () => {
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
});
|
||||
|
||||
const summary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async ({ run }) => `/tmp/reviews/${run.reviewerSessionId}`,
|
||||
runReviewer: async ({ workspacePath }) => ({
|
||||
summary: `Reviewed ${workspacePath}`,
|
||||
findings: [
|
||||
{
|
||||
severity: "error",
|
||||
title: "Broken save path",
|
||||
body: "The save handler drops failed writes.",
|
||||
filePath: "src/save.ts",
|
||||
startLine: 12,
|
||||
confidence: 0.9,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
status: "needs_triage",
|
||||
reviewerWorkspacePath: "/tmp/reviews/app-rev-1",
|
||||
findingCount: 1,
|
||||
openFindingCount: 1,
|
||||
summary: "Reviewed /tmp/reviews/app-rev-1",
|
||||
});
|
||||
expect(store.listFindings({ runId: run.id })[0]).toMatchObject({
|
||||
severity: "error",
|
||||
title: "Broken save path",
|
||||
filePath: "src/save.ts",
|
||||
startLine: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the project default branch when the session PR base branch is empty", async () => {
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
});
|
||||
let observedBaseRef: string | undefined;
|
||||
const session = makeSession({
|
||||
pr: {
|
||||
...makeSession().pr!,
|
||||
baseBranch: "",
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => "/tmp/reviews/app-rev-1",
|
||||
runReviewer: async ({ baseRef }) => {
|
||||
observedBaseRef = baseRef;
|
||||
return { findings: [] };
|
||||
},
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
|
||||
expect(observedBaseRef).toBe("main");
|
||||
expect(summary.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("allows only one concurrent execution to claim the same queued review run", async () => {
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
targetSha: "abc123",
|
||||
});
|
||||
let resolveSessionLookup: (() => void) | undefined;
|
||||
const sessionLookupGate = new Promise<void>((resolve) => {
|
||||
resolveSessionLookup = resolve;
|
||||
});
|
||||
let sessionLookups = 0;
|
||||
let prepareCalls = 0;
|
||||
const sessionManager = makeSessionManager(makeSession(), {
|
||||
get: async () => {
|
||||
sessionLookups++;
|
||||
if (sessionLookups === 2) {
|
||||
resolveSessionLookup?.();
|
||||
}
|
||||
await sessionLookupGate;
|
||||
return makeSession();
|
||||
},
|
||||
});
|
||||
|
||||
const first = executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => {
|
||||
prepareCalls++;
|
||||
return "/tmp/reviews/app-rev-1";
|
||||
},
|
||||
runReviewer: async () => ({ findings: [] }),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
while (sessionLookups === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const second = executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager,
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => {
|
||||
prepareCalls++;
|
||||
return "/tmp/reviews/app-rev-1";
|
||||
},
|
||||
runReviewer: async () => ({ findings: [] }),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
await Promise.resolve();
|
||||
resolveSessionLookup?.();
|
||||
|
||||
const results = await Promise.allSettled([first, second]);
|
||||
|
||||
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
||||
expect(prepareCalls).toBe(1);
|
||||
expect(store.getRun(run.id)?.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("marks clean reviews clean and records failed reviewer executions", async () => {
|
||||
const cleanRun = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
const cleanSummary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => "/tmp/reviews/app-rev-1",
|
||||
runReviewer: async () => ({ rawOutput: '{"findings":[]}' }),
|
||||
},
|
||||
{ projectId: "app", runId: cleanRun.id },
|
||||
);
|
||||
expect(cleanSummary.status).toBe("clean");
|
||||
expect(cleanSummary.findingCount).toBe(0);
|
||||
|
||||
const failedRun = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "queued",
|
||||
});
|
||||
const failedSummary = await executeCodeReviewRun(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(makeSession()),
|
||||
storeFactory: () => store,
|
||||
prepareWorkspace: async () => "/tmp/reviews/app-rev-2",
|
||||
runReviewer: async () => {
|
||||
throw new Error("review command crashed");
|
||||
},
|
||||
},
|
||||
{ projectId: "app", runId: failedRun.id },
|
||||
);
|
||||
expect(failedSummary.status).toBe("failed");
|
||||
expect(failedSummary.terminationReason).toBe("review command crashed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendCodeReviewFindingsToAgent", () => {
|
||||
it("sends open review findings to the linked coding worker and marks them sent", async () => {
|
||||
const session = makeSession();
|
||||
const sentMessages: Array<{ sessionId: string; message: string }> = [];
|
||||
const run = store.createRun({
|
||||
linkedSessionId: session.id,
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
targetSha: "abc123",
|
||||
prNumber: 7,
|
||||
prUrl: "https://github.com/acme/app/pull/7",
|
||||
});
|
||||
const first = store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "error",
|
||||
title: "Broken save path",
|
||||
body: "The save handler drops failed writes.",
|
||||
filePath: "src/save.ts",
|
||||
startLine: 12,
|
||||
confidence: 0.9,
|
||||
});
|
||||
const second = store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "warning",
|
||||
title: "Missing retry",
|
||||
body: "The request fails permanently on transient network errors.",
|
||||
filePath: "src/api.ts",
|
||||
startLine: 4,
|
||||
endLine: 8,
|
||||
});
|
||||
const dismissed = store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "info",
|
||||
title: "Dismissed nit",
|
||||
body: "This should not be sent.",
|
||||
status: "dismissed",
|
||||
});
|
||||
|
||||
const result = await sendCodeReviewFindingsToAgent(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session, {
|
||||
send: async (sessionId, message) => {
|
||||
sentMessages.push({ sessionId, message });
|
||||
},
|
||||
}),
|
||||
storeFactory: () => store,
|
||||
now: () => new Date("2026-05-10T12:00:00.000Z"),
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
);
|
||||
|
||||
expect(sentMessages).toHaveLength(1);
|
||||
expect(sentMessages[0]?.sessionId).toBe("app-1");
|
||||
expect(sentMessages[0]?.message).toContain("AO reviewer app-rev-1 found 2 open issues");
|
||||
expect(sentMessages[0]?.message).toContain("Review run:");
|
||||
expect(sentMessages[0]?.message).toContain("[error] Broken save path");
|
||||
expect(sentMessages[0]?.message).toContain("Location: src/save.ts:12");
|
||||
expect(sentMessages[0]?.message).toContain("[warning] Missing retry");
|
||||
expect(sentMessages[0]?.message).toContain("Location: src/api.ts:4-8");
|
||||
expect(sentMessages[0]?.message).not.toContain("Dismissed nit");
|
||||
expect(result).toMatchObject({
|
||||
sentFindingCount: 2,
|
||||
run: {
|
||||
status: "waiting_update",
|
||||
openFindingCount: 0,
|
||||
sentFindingCount: 2,
|
||||
dismissedFindingCount: 1,
|
||||
},
|
||||
});
|
||||
expect(store.getFinding(first.id)).toMatchObject({
|
||||
status: "sent_to_agent",
|
||||
sentToAgentAt: "2026-05-10T12:00:00.000Z",
|
||||
});
|
||||
expect(store.getFinding(second.id)?.status).toBe("sent_to_agent");
|
||||
expect(store.getFinding(dismissed.id)?.status).toBe("dismissed");
|
||||
});
|
||||
|
||||
it("does not send or mutate when there are no open findings", async () => {
|
||||
const session = makeSession();
|
||||
const run = store.createRun({
|
||||
linkedSessionId: session.id,
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "clean",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: run.id,
|
||||
linkedSessionId: session.id,
|
||||
severity: "warning",
|
||||
title: "Already sent",
|
||||
body: "Do not resend this.",
|
||||
status: "sent_to_agent",
|
||||
});
|
||||
const sentMessages: string[] = [];
|
||||
|
||||
await expect(
|
||||
sendCodeReviewFindingsToAgent(
|
||||
{
|
||||
config,
|
||||
sessionManager: makeSessionManager(session, {
|
||||
send: async (_sessionId, message) => {
|
||||
sentMessages.push(message);
|
||||
},
|
||||
}),
|
||||
storeFactory: () => store,
|
||||
},
|
||||
{ projectId: "app", runId: run.id },
|
||||
),
|
||||
).rejects.toBeInstanceOf(CodeReviewNoOpenFindingsError);
|
||||
|
||||
expect(sentMessages).toEqual([]);
|
||||
expect(store.getRun(run.id)?.status).toBe("clean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCodexCodeReview", () => {
|
||||
it("uses generic codex exec instead of the review subcommand base/prompt combination", () => {
|
||||
const args = buildCodexCodeReviewArgs("/tmp/review-output.json", "Return JSON only.");
|
||||
|
||||
expect(args).toEqual([
|
||||
"exec",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--output-last-message",
|
||||
"/tmp/review-output.json",
|
||||
"Return JSON only.",
|
||||
]);
|
||||
expect(args).not.toContain("review");
|
||||
expect(args).not.toContain("--base");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareGitReviewerWorkspace", () => {
|
||||
it("prunes stale git worktree metadata when the reviewer workspace directory is gone", async () => {
|
||||
const tmpHome = join(tmpdir(), `ao-test-review-worktree-${randomUUID()}`);
|
||||
const originalHome = process.env["HOME"];
|
||||
process.env["HOME"] = tmpHome;
|
||||
|
||||
try {
|
||||
const repoPath = join(tmpHome, "repo");
|
||||
mkdirSync(repoPath, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repoPath });
|
||||
writeFileSync(join(repoPath, "README.md"), "# App\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoPath });
|
||||
execFileSync("git", ["commit", "-m", "initial"], {
|
||||
cwd: repoPath,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: "AO Test",
|
||||
GIT_AUTHOR_EMAIL: "ao@example.com",
|
||||
GIT_COMMITTER_NAME: "AO Test",
|
||||
GIT_COMMITTER_EMAIL: "ao@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const run = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-stale",
|
||||
status: "queued",
|
||||
});
|
||||
const project = { ...config.projects.app!, path: repoPath };
|
||||
const workspaceRoot = join(
|
||||
tmpHome,
|
||||
".agent-orchestrator",
|
||||
"projects",
|
||||
"app",
|
||||
"code-reviews",
|
||||
"workspaces",
|
||||
);
|
||||
const workspacePath = join(workspaceRoot, run.reviewerSessionId);
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", "--detach", workspacePath, "HEAD"], {
|
||||
cwd: repoPath,
|
||||
});
|
||||
rmSync(workspacePath, { recursive: true, force: true });
|
||||
|
||||
const preparedPath = await prepareGitReviewerWorkspace({
|
||||
projectId: "app",
|
||||
project,
|
||||
session: makeSession({ workspacePath: repoPath }),
|
||||
run,
|
||||
});
|
||||
|
||||
expect(preparedPath).toBe(workspacePath);
|
||||
expect(existsSync(workspacePath)).toBe(true);
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env["HOME"];
|
||||
} else {
|
||||
process.env["HOME"] = originalHome;
|
||||
}
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReviewerOutput", () => {
|
||||
it("parses JSON findings and falls back to a single reviewer-output finding", () => {
|
||||
expect(
|
||||
parseReviewerOutput(
|
||||
JSON.stringify({
|
||||
findings: [{ severity: "warning", title: "Risk", body: "A concrete issue." }],
|
||||
}),
|
||||
),
|
||||
).toMatchObject([{ severity: "warning", title: "Risk", body: "A concrete issue." }]);
|
||||
expect(parseReviewerOutput("No findings.")).toEqual([]);
|
||||
expect(parseReviewerOutput("Unexpected reviewer text")).toMatchObject([
|
||||
{ severity: "warning", title: "Reviewer output", body: "Unexpected reviewer text" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not drop structured findings whose text mentions no findings", () => {
|
||||
expect(
|
||||
parseReviewerOutput(
|
||||
JSON.stringify({
|
||||
findings: [
|
||||
{
|
||||
severity: "warning",
|
||||
title: "No findings banner is stale",
|
||||
body: "The UI still says no findings even when the reviewer found one.",
|
||||
filePath: "src/review.ts",
|
||||
startLine: 42,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toMatchObject([
|
||||
{
|
||||
severity: "warning",
|
||||
title: "No findings banner is stale",
|
||||
body: "The UI still says no findings even when the reviewer found one.",
|
||||
filePath: "src/review.ts",
|
||||
startLine: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createCodeReviewStore, type CodeReviewStore } from "../code-review-store.js";
|
||||
|
||||
let storeDir: string;
|
||||
let store: CodeReviewStore;
|
||||
|
||||
beforeEach(() => {
|
||||
storeDir = join(tmpdir(), `ao-test-code-review-store-${randomUUID()}`);
|
||||
mkdirSync(storeDir, { recursive: true });
|
||||
store = createCodeReviewStore("app", { storeDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(storeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("CodeReviewStore", () => {
|
||||
it("creates runs and lists summaries with finding counts", () => {
|
||||
const run = store.createRun(
|
||||
{
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
targetSha: "abc123",
|
||||
prNumber: 42,
|
||||
},
|
||||
new Date("2026-05-10T10:00:00.000Z"),
|
||||
);
|
||||
|
||||
store.createFinding(
|
||||
{
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "error",
|
||||
title: "Missing guard",
|
||||
body: "The handler reads a nullable value without checking it.",
|
||||
filePath: "src/auth.ts",
|
||||
startLine: 12,
|
||||
fingerprint: "auth-guard",
|
||||
},
|
||||
new Date("2026-05-10T10:01:00.000Z"),
|
||||
);
|
||||
const dismissed = store.createFinding(
|
||||
{
|
||||
runId: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
severity: "info",
|
||||
title: "Naming nit",
|
||||
body: "Consider a clearer name.",
|
||||
},
|
||||
new Date("2026-05-10T10:02:00.000Z"),
|
||||
);
|
||||
store.updateFinding(dismissed.id, { status: "dismissed", dismissedBy: "operator" });
|
||||
|
||||
const summaries = store.listRunSummaries({ linkedSessionId: "app-1" });
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
id: run.id,
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "needs_triage",
|
||||
findingCount: 2,
|
||||
openFindingCount: 1,
|
||||
dismissedFindingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters runs and findings independently", () => {
|
||||
const first = store.createRun({
|
||||
linkedSessionId: "app-1",
|
||||
reviewerSessionId: "app-rev-1",
|
||||
status: "clean",
|
||||
});
|
||||
const second = store.createRun({
|
||||
linkedSessionId: "app-2",
|
||||
reviewerSessionId: "app-rev-2",
|
||||
status: "failed",
|
||||
});
|
||||
store.createFinding({
|
||||
runId: second.id,
|
||||
linkedSessionId: "app-2",
|
||||
severity: "warning",
|
||||
title: "Race condition",
|
||||
body: "This update can be lost.",
|
||||
});
|
||||
|
||||
expect(store.listRuns({ status: "clean" }).map((run) => run.id)).toEqual([first.id]);
|
||||
expect(store.listRuns({ linkedSessionId: "app-2" }).map((run) => run.id)).toEqual([second.id]);
|
||||
expect(store.listFindings({ linkedSessionId: "app-1" })).toEqual([]);
|
||||
expect(store.listFindings({ linkedSessionId: "app-2" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects path traversal ids", () => {
|
||||
expect(() => store.getRun("../bad")).toThrow(/Unsafe review run id/);
|
||||
expect(() => store.getFinding("../bad")).toThrow(/Unsafe review finding id/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1247,3 +1247,61 @@ describe("Config Validation - Power Config", () => {
|
|||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config Validation - Observability Config", () => {
|
||||
const baseConfig = {
|
||||
projects: {
|
||||
proj1: {
|
||||
path: "/repos/test",
|
||||
repo: "org/test",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("applies default observability config", () => {
|
||||
const config = validateConfig(baseConfig);
|
||||
|
||||
expect(config.observability).toEqual({
|
||||
logLevel: "warn",
|
||||
stderr: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts explicit observability settings", () => {
|
||||
const config = validateConfig({
|
||||
...baseConfig,
|
||||
observability: {
|
||||
logLevel: "info",
|
||||
stderr: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.observability).toEqual({
|
||||
logLevel: "info",
|
||||
stderr: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid observability settings", () => {
|
||||
expect(() =>
|
||||
validateConfig({
|
||||
...baseConfig,
|
||||
observability: {
|
||||
logLevel: "verbose",
|
||||
stderr: false,
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
validateConfig({
|
||||
...baseConfig,
|
||||
observability: {
|
||||
logLevel: "warn",
|
||||
stderr: "yes",
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OrchestratorEvent } from "../types.js";
|
||||
import { buildCIFailureNotificationData } from "../notification-data.js";
|
||||
import {
|
||||
appendDashboardNotificationRecord,
|
||||
createDashboardNotificationRecord,
|
||||
normalizeDashboardNotificationLimit,
|
||||
readDashboardNotificationsFromFile,
|
||||
writeDashboardNotificationsToFile,
|
||||
} from "../dashboard-notifications.js";
|
||||
|
||||
let tempDir: string | null = null;
|
||||
|
||||
function makeTempPath(): string {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "ao-dashboard-notifications-"));
|
||||
return join(tempDir, "dashboard-notifications.jsonl");
|
||||
}
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
sessionId: "app-1",
|
||||
projectId: "demo",
|
||||
timestamp: new Date("2026-05-13T10:00:00.000Z"),
|
||||
message: "CI is failing",
|
||||
data: buildCIFailureNotificationData({
|
||||
sessionId: "app-1",
|
||||
projectId: "demo",
|
||||
context: {
|
||||
pr: {
|
||||
number: 1,
|
||||
url: "https://github.com/acme/app/pull/1",
|
||||
title: "Fix dashboard notifications",
|
||||
branch: "fix/dashboard-notifications",
|
||||
baseBranch: "main",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
isDraft: false,
|
||||
},
|
||||
issueId: "AO-1",
|
||||
issueTitle: "Fix dashboard notifications",
|
||||
summary: "Fix dashboard notifications",
|
||||
branch: "fix/dashboard-notifications",
|
||||
},
|
||||
failedChecks: [{ name: "typecheck", status: "failed", conclusion: "FAILURE" }],
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
});
|
||||
|
||||
describe("dashboard notifications", () => {
|
||||
it("serializes events and actions into dashboard records", () => {
|
||||
const record = createDashboardNotificationRecord(
|
||||
makeEvent(),
|
||||
[{ label: "View PR", url: "https://github.com/acme/app/pull/1" }],
|
||||
new Date("2026-05-13T11:00:00.000Z"),
|
||||
);
|
||||
|
||||
expect(record.id).toBe("evt-1:2026-05-13T11:00:00.000Z");
|
||||
expect(record.event.timestamp).toBe("2026-05-13T10:00:00.000Z");
|
||||
expect(record.event.data).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
subject: { pr: { url: "https://github.com/acme/app/pull/1" } },
|
||||
ci: { status: "failing" },
|
||||
});
|
||||
expect(record.event.data.prUrl).toBeUndefined();
|
||||
expect(record.actions).toEqual([
|
||||
{ label: "View PR", url: "https://github.com/acme/app/pull/1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes and reads JSONL records", () => {
|
||||
const filePath = makeTempPath();
|
||||
const record = createDashboardNotificationRecord(makeEvent());
|
||||
|
||||
writeDashboardNotificationsToFile(filePath, [record], 50);
|
||||
|
||||
expect(readDashboardNotificationsFromFile(filePath)).toEqual([record]);
|
||||
expect(readFileSync(filePath, "utf-8")).toContain('"sessionId":"app-1"');
|
||||
});
|
||||
|
||||
it("retains only the latest limit records", () => {
|
||||
const filePath = makeTempPath();
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
appendDashboardNotificationRecord(
|
||||
filePath,
|
||||
createDashboardNotificationRecord(
|
||||
makeEvent({ id: `evt-${i}` }),
|
||||
undefined,
|
||||
new Date(`2026-05-13T11:00:0${i}.000Z`),
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
readDashboardNotificationsFromFile(filePath, 50).map((record) => record.event.id),
|
||||
).toEqual(["evt-3", "evt-4"]);
|
||||
});
|
||||
|
||||
it("skips malformed JSONL lines", () => {
|
||||
const filePath = makeTempPath();
|
||||
const record = createDashboardNotificationRecord(makeEvent());
|
||||
writeFileSync(filePath, `not-json\n${JSON.stringify(record)}\n{"id":"bad"}\n`, "utf-8");
|
||||
|
||||
expect(readDashboardNotificationsFromFile(filePath)).toEqual([record]);
|
||||
});
|
||||
|
||||
it("keeps legacy JSONL records readable without transforming their data", () => {
|
||||
const filePath = makeTempPath();
|
||||
const legacyRecord = {
|
||||
id: "evt-legacy:2026-05-13T11:00:00.000Z",
|
||||
receivedAt: "2026-05-13T11:00:00.000Z",
|
||||
event: {
|
||||
id: "evt-legacy",
|
||||
type: "ci.failing",
|
||||
priority: "warning",
|
||||
sessionId: "app-1",
|
||||
projectId: "demo",
|
||||
timestamp: "2026-05-13T10:00:00.000Z",
|
||||
message: "CI is failing",
|
||||
data: { schemaVersion: 2, prUrl: "https://github.com/acme/app/pull/1" },
|
||||
},
|
||||
};
|
||||
writeFileSync(filePath, `${JSON.stringify(legacyRecord)}\n`, "utf-8");
|
||||
|
||||
expect(readDashboardNotificationsFromFile(filePath)).toEqual([legacyRecord]);
|
||||
});
|
||||
|
||||
it("clamps invalid limits", () => {
|
||||
expect(normalizeDashboardNotificationLimit(undefined)).toBe(50);
|
||||
expect(normalizeDashboardNotificationLimit(0)).toBe(1);
|
||||
expect(normalizeDashboardNotificationLimit("1000")).toBe(500);
|
||||
expect(normalizeDashboardNotificationLimit("75")).toBe(75);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
__resetActivityEventsDbWarningForTests,
|
||||
emitActivityEventsDbUnavailableWarning,
|
||||
formatActivityEventsDbUnavailableWarning,
|
||||
} from "../events-db.js";
|
||||
|
||||
describe("activity-events DB unavailable warning", () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalDebug = process.env["AO_DEBUG"];
|
||||
|
||||
beforeEach(() => {
|
||||
__resetActivityEventsDbWarningForTests();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
delete process.env["AO_DEBUG"];
|
||||
process.argv = ["node", "ao"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
if (originalDebug === undefined) {
|
||||
delete process.env["AO_DEBUG"];
|
||||
} else {
|
||||
process.env["AO_DEBUG"] = originalDebug;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("formats missing native binding errors without the bindings search path", () => {
|
||||
const message = formatActivityEventsDbUnavailableWarning(
|
||||
new Error(
|
||||
"Could not locate the bindings file. Tried:\n → /tmp/build/Release/better_sqlite3.node",
|
||||
),
|
||||
);
|
||||
|
||||
expect(message).toBe(
|
||||
`[ao] activity-events disabled: better-sqlite3 not compiled for Node ${process.version} (ABI v${process.versions.modules}). Run \`pnpm rebuild better-sqlite3\` or use a supported Node version.`,
|
||||
);
|
||||
expect(message).not.toContain("Tried:");
|
||||
expect(message).not.toContain("/tmp/build/Release");
|
||||
});
|
||||
|
||||
it("prints the runtime warning once per process", () => {
|
||||
process.env["AO_DEBUG"] = "1";
|
||||
const err = new Error(
|
||||
"Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node",
|
||||
);
|
||||
|
||||
emitActivityEventsDbUnavailableWarning(err);
|
||||
emitActivityEventsDbUnavailableWarning(err);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.[0]).toContain(
|
||||
"activity-events disabled: better-sqlite3 not compiled",
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses non-events invocations unless AO_DEBUG=1", () => {
|
||||
const err = new Error(
|
||||
"Could not locate the bindings file. Tried:\n → /tmp/better_sqlite3.node",
|
||||
);
|
||||
|
||||
process.argv = ["node", "ao", "spawn", "demo"];
|
||||
emitActivityEventsDbUnavailableWarning(err);
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
|
||||
process.argv = ["node", "ao", "events", "stats"];
|
||||
emitActivityEventsDbUnavailableWarning(err);
|
||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -351,6 +351,57 @@ describe("global-config storage identity", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves wrapped config defaults when repairing local behavior", () => {
|
||||
const repoPath = createRepo("wrapped-local-defaults", "https://github.com/OpenAI/demo.git");
|
||||
const projectId = registerProjectInGlobalConfig(
|
||||
"wrapped-local-defaults",
|
||||
"Wrapped Local Defaults",
|
||||
repoPath,
|
||||
{ defaultBranch: "main" },
|
||||
configPath,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repoPath, "agent-orchestrator.yaml"),
|
||||
[
|
||||
"defaults:",
|
||||
" agent: codex",
|
||||
" runtime: tmux",
|
||||
" workspace: worktree",
|
||||
" orchestrator:",
|
||||
" agent: codex",
|
||||
" worker:",
|
||||
" agent: opencode",
|
||||
"projects:",
|
||||
" wrapped-local-defaults:",
|
||||
` path: ${repoPath}`,
|
||||
" name: Wrapped Local Defaults",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({
|
||||
resolveError: expect.stringContaining("wrapped projects: format"),
|
||||
});
|
||||
|
||||
repairWrappedLocalProjectConfig(projectId, repoPath);
|
||||
|
||||
const repaired = parseYaml(readFileSync(join(repoPath, "agent-orchestrator.yaml"), "utf-8"));
|
||||
expect(repaired).toEqual({
|
||||
agent: "codex",
|
||||
runtime: "tmux",
|
||||
workspace: "worktree",
|
||||
orchestrator: { agent: "codex" },
|
||||
worker: { agent: "opencode" },
|
||||
});
|
||||
expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({
|
||||
agent: "codex",
|
||||
runtime: "tmux",
|
||||
workspace: "worktree",
|
||||
orchestrator: { agent: "codex" },
|
||||
worker: { agent: "opencode" },
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs wrapped local .yml configs without creating a .yaml sibling", () => {
|
||||
const repoPath = createRepo("wrapped-local-yml", "https://github.com/OpenAI/demo.git");
|
||||
const configPathYml = join(repoPath, "agent-orchestrator.yml");
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
* test that proves the lifecycle check completes successfully even if
|
||||
* recordActivityEvent itself throws (B2 invariant).
|
||||
*
|
||||
* Notifier instrumentation is intentionally omitted — the notifier subsystem
|
||||
* is undergoing larger work and AE evidence there is not currently useful.
|
||||
* Notification delivery instrumentation is covered in lifecycle-manager.test.ts
|
||||
* because it needs real observability snapshots in addition to activity events.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import type * as ActivityEventsModule from "../activity-events.js";
|
||||
|
|
|
|||
|
|
@ -1770,7 +1770,11 @@ describe("check (single session)", () => {
|
|||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "reaction.triggered",
|
||||
data: expect.objectContaining({ reactionKey: "pr-closed" }),
|
||||
data: expect.objectContaining({
|
||||
schemaVersion: 3,
|
||||
semanticType: "pr.closed",
|
||||
reaction: expect.objectContaining({ key: "pr-closed" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1867,10 +1871,11 @@ describe("reactions", () => {
|
|||
|
||||
const reactionNotifications = vi.mocked(notifier.notify).mock.calls.filter((call) => {
|
||||
const event = call[0] as { type?: string; data?: Record<string, unknown> } | undefined;
|
||||
return (
|
||||
event?.type === "reaction.triggered" &&
|
||||
event.data?.["reactionKey"] === "report-no-acknowledge"
|
||||
);
|
||||
const reaction =
|
||||
event?.data?.reaction && typeof event.data.reaction === "object"
|
||||
? (event.data.reaction as Record<string, unknown>)
|
||||
: null;
|
||||
return event?.type === "reaction.triggered" && reaction?.key === "report-no-acknowledge";
|
||||
});
|
||||
|
||||
expect(reactionNotifications).toHaveLength(1);
|
||||
|
|
@ -2839,6 +2844,112 @@ describe("reactions", () => {
|
|||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "merge.completed" }),
|
||||
);
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.success).toBe(1);
|
||||
expect(
|
||||
summary.projects["my-app"]?.recentTraces.some(
|
||||
(trace) =>
|
||||
trace.operation === "notification.deliver" &&
|
||||
trace.outcome === "success" &&
|
||||
trace.data?.["targetReference"] === "desktop",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("records notifier delivery failures without interrupting lifecycle transitions", async () => {
|
||||
const notifier = createMockNotifier();
|
||||
vi.mocked(notifier.notify).mockRejectedValue(new Error("webhook failed"));
|
||||
const mockSCM = createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }),
|
||||
});
|
||||
|
||||
const registry: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return plugins.runtime;
|
||||
if (slot === "agent") return plugins.agent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
if (slot === "notifier" && name === "desktop") return notifier;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "approved", pr: makePR() }),
|
||||
registry,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("merged");
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "notifier",
|
||||
kind: "notification.delivery_failed",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({
|
||||
eventType: "merge.completed",
|
||||
targetReference: "desktop",
|
||||
targetPlugin: "desktop",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = readObservabilitySummary(config);
|
||||
expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1);
|
||||
expect(summary.projects["my-app"]?.health["notification.delivery.desktop"]?.status).toBe(
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
it("records missing notifier targets as delivery failures", async () => {
|
||||
const mockSCM = createMockSCM({
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }),
|
||||
});
|
||||
const configWithMissingNotifier: OrchestratorConfig = {
|
||||
...config,
|
||||
notificationRouting: {
|
||||
...config.notificationRouting,
|
||||
action: ["missing"],
|
||||
},
|
||||
};
|
||||
|
||||
const registry: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return plugins.runtime;
|
||||
if (slot === "agent") return plugins.agent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({ status: "approved", pr: makePR() }),
|
||||
registry,
|
||||
configOverride: configWithMissingNotifier,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "notifier",
|
||||
kind: "notification.target_missing",
|
||||
level: "warn",
|
||||
data: expect.objectContaining({
|
||||
eventType: "merge.completed",
|
||||
targetReference: "missing",
|
||||
targetPlugin: "missing",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = readObservabilitySummary(configWithMissingNotifier);
|
||||
expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves notifier aliases from notificationRouting before dispatch", async () => {
|
||||
|
|
@ -3344,22 +3455,34 @@ describe("reactions", () => {
|
|||
// Reach escalated state: attempt 1 → send, attempt 2 → escalate
|
||||
await lm.check("app-1"); // pr_open → ci_failed: attempt 1, send
|
||||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1"); // ci_failed → pr_open
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1"); // pr_open → ci_failed: attempt 2 → escalate
|
||||
expect(notifier.notify).toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" }));
|
||||
expect(notifier.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "reaction.escalated" }),
|
||||
);
|
||||
vi.mocked(notifier.notify).mockClear();
|
||||
|
||||
// ONE passing poll (stableCount = 1, not enough)
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
|
||||
// Next CI failure: tracker still escalated → short-circuit
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).not.toHaveBeenCalled();
|
||||
expect(notifier.notify).not.toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" }));
|
||||
expect(notifier.notify).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "reaction.escalated" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("pending CI does not count toward ci-failed tracker resolution", async () => {
|
||||
|
|
@ -3401,12 +3524,16 @@ describe("reactions", () => {
|
|||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
|
||||
// CI goes pending (agent pushed a fix, new run started): ci_failed → pr_open
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "pending" }),
|
||||
);
|
||||
await lm.check("app-1"); // stableCount must NOT increment
|
||||
await lm.check("app-1"); // two pending polls — must NOT clear tracker
|
||||
|
||||
// CI fails again (run completed failing): pr_open → ci_failed, attempt 2 — send
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
// If pending had wrongly cleared the tracker, this would be attempt 1 (fresh), not attempt 2.
|
||||
// Attempt 2 ≤ retries:2 → sends to agent (not escalates)
|
||||
|
|
@ -3414,10 +3541,14 @@ describe("reactions", () => {
|
|||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
|
||||
// CI goes pending again, then failing — attempt 3 > retries:2 → escalate
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "pending" }),
|
||||
);
|
||||
await lm.check("app-1"); // pending: no clear
|
||||
await lm.check("app-1"); // pending: no clear
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).not.toHaveBeenCalled(); // escalated, not sent to agent
|
||||
});
|
||||
|
|
@ -3461,25 +3592,35 @@ describe("reactions", () => {
|
|||
// Reach escalated state: attempt 1 → send, attempt 2 → escalate
|
||||
await lm.check("app-1"); // attempt 1, send
|
||||
vi.mocked(mockSessionManager.send).mockClear();
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1"); // ci_failed → pr_open
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1"); // attempt 2 → escalate
|
||||
vi.mocked(notifier.notify).mockClear();
|
||||
|
||||
// CI goes pending (new run) — stableCount stays 0, does NOT progress toward resolution
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "pending" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
await lm.check("app-1");
|
||||
await lm.check("app-1"); // many pending polls — stableCount never reaches threshold
|
||||
|
||||
// CI finally passes (2 stable polls) → tracker cleared
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "passing" }),
|
||||
);
|
||||
await lm.check("app-1"); // stableCount = 1
|
||||
await lm.check("app-1"); // stableCount = 2 → clearReactionTracker
|
||||
|
||||
// Next CI failure gets fresh budget: attempt 1, send
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" }));
|
||||
vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(
|
||||
mockBatchEnrichment({ ciStatus: "failing" }),
|
||||
);
|
||||
await lm.check("app-1");
|
||||
expect(mockSessionManager.send).toHaveBeenCalledTimes(1);
|
||||
expect(notifier.notify).not.toHaveBeenCalledWith(
|
||||
|
|
@ -3632,7 +3773,11 @@ describe("pollAll terminal status accounting", () => {
|
|||
.mock.calls.filter((call: unknown[]) => {
|
||||
const event = call[0] as Record<string, unknown> | undefined;
|
||||
const data = event?.data as Record<string, unknown> | undefined;
|
||||
return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete";
|
||||
const reaction =
|
||||
data?.reaction && typeof data.reaction === "object"
|
||||
? (data.reaction as Record<string, unknown>)
|
||||
: null;
|
||||
return event?.type === "reaction.triggered" && reaction?.key === "all-complete";
|
||||
});
|
||||
expect(allCompleteNotifications).toHaveLength(0);
|
||||
|
||||
|
|
@ -4210,14 +4355,19 @@ describe("event enrichment", () => {
|
|||
expect.objectContaining({
|
||||
type: "pr.closed",
|
||||
data: expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
schemaVersion: 3,
|
||||
subject: expect.objectContaining({
|
||||
pr: expect.objectContaining({
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
number: 42,
|
||||
}),
|
||||
branch: "feat/test-123",
|
||||
}),
|
||||
schemaVersion: 2,
|
||||
transition: expect.objectContaining({
|
||||
kind: "pr_state",
|
||||
from: "none",
|
||||
to: "closed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -4257,11 +4407,13 @@ describe("event enrichment", () => {
|
|||
expect.objectContaining({
|
||||
type: "pr.closed",
|
||||
data: expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
issueId: "INT-123",
|
||||
issueTitle: "Fix login bug",
|
||||
schemaVersion: 3,
|
||||
subject: expect.objectContaining({
|
||||
issue: {
|
||||
id: "INT-123",
|
||||
title: "Fix login bug",
|
||||
},
|
||||
}),
|
||||
schemaVersion: 2,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -4305,11 +4457,15 @@ describe("event enrichment", () => {
|
|||
expect.objectContaining({
|
||||
type: "session.needs_input",
|
||||
data: expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
pr: null,
|
||||
issueId: "INT-456",
|
||||
schemaVersion: 3,
|
||||
subject: expect.objectContaining({
|
||||
issue: { id: "INT-456" },
|
||||
}),
|
||||
transition: expect.objectContaining({
|
||||
kind: "session_status",
|
||||
from: "working",
|
||||
to: "needs_input",
|
||||
}),
|
||||
schemaVersion: 2,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, renameSync } from "node:fs";
|
||||
import type * as NodeFs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
|
@ -13,12 +14,29 @@ import {
|
|||
deleteMetadata,
|
||||
listMetadata,
|
||||
} from "../metadata.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>();
|
||||
return {
|
||||
...actual,
|
||||
renameSync: vi.fn((...args: Parameters<typeof actual.renameSync>) =>
|
||||
actual.renameSync(...args),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-metadata-${randomUUID()}`);
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
vi.mocked(renameSync).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -390,6 +408,123 @@ describe("mutateMetadata corrupt-file handling", () => {
|
|||
const corruptCopies = readdirSync(dataDir).filter((f) => f.includes(".corrupt-"));
|
||||
expect(corruptCopies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits metadata.corrupt_detected when JSON parse fails and file is renamed", () => {
|
||||
const sessionPath = join(dataDir, "ao-3.json");
|
||||
writeFileSync(sessionPath, "{ broken json", "utf-8");
|
||||
|
||||
const result = mutateMetadata(
|
||||
dataDir,
|
||||
"ao-3",
|
||||
(existing) => ({ ...existing, branch: "feat/x" }),
|
||||
{ createIfMissing: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "ao-3",
|
||||
source: "session-manager",
|
||||
kind: "metadata.corrupt_detected",
|
||||
level: "error",
|
||||
summary: expect.stringContaining("renamed to"),
|
||||
data: expect.objectContaining({
|
||||
renamedTo: expect.stringContaining(`${sessionPath}.corrupt-`),
|
||||
renameSucceeded: true,
|
||||
contentSample: "{ broken json",
|
||||
path: sessionPath,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a rename-failed summary when corrupt metadata cannot be renamed", () => {
|
||||
const sessionPath = join(dataDir, "ao-rename-failed.json");
|
||||
writeFileSync(sessionPath, "{ broken json", "utf-8");
|
||||
vi.mocked(renameSync).mockImplementationOnce(() => {
|
||||
throw new Error("rename denied");
|
||||
});
|
||||
|
||||
const result = mutateMetadata(
|
||||
dataDir,
|
||||
"ao-rename-failed",
|
||||
(existing) => ({ ...existing, branch: "feat/x" }),
|
||||
{ createIfMissing: true },
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const call = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.find((c) => c[0].kind === "metadata.corrupt_detected");
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toMatchObject({
|
||||
sessionId: "ao-rename-failed",
|
||||
summary: expect.stringContaining("failed to rename"),
|
||||
data: expect.objectContaining({
|
||||
renamedTo: null,
|
||||
renameSucceeded: false,
|
||||
path: sessionPath,
|
||||
}),
|
||||
});
|
||||
expect(call![0].summary).not.toContain("renamed to");
|
||||
});
|
||||
|
||||
it("uses the provided source for metadata.corrupt_detected", () => {
|
||||
const sessionPath = join(dataDir, "ao-api-source.json");
|
||||
writeFileSync(sessionPath, "{ broken json", "utf-8");
|
||||
|
||||
mutateMetadata(
|
||||
dataDir,
|
||||
"ao-api-source",
|
||||
(existing) => ({ ...existing, branch: "feat/api" }),
|
||||
{ createIfMissing: true, activityEventSource: "api" },
|
||||
);
|
||||
|
||||
expect(recordActivityEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "ao-api-source",
|
||||
source: "api",
|
||||
kind: "metadata.corrupt_detected",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates contentSample to 200 chars in metadata.corrupt_detected", () => {
|
||||
const sessionPath = join(dataDir, "ao-4.json");
|
||||
// 250 char garbage payload — sanitizer cap is 16KB but invariant B11 caps
|
||||
// forensic sample at 200 chars.
|
||||
const huge = "x".repeat(250);
|
||||
writeFileSync(sessionPath, huge, "utf-8");
|
||||
|
||||
mutateMetadata(
|
||||
dataDir,
|
||||
"ao-4",
|
||||
(existing) => ({ ...existing, branch: "feat/y" }),
|
||||
{ createIfMissing: true },
|
||||
);
|
||||
|
||||
const call = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.find((c) => c[0].kind === "metadata.corrupt_detected");
|
||||
expect(call).toBeDefined();
|
||||
const sample = (call![0].data as Record<string, unknown>)["contentSample"] as string;
|
||||
expect(sample.length).toBe(200);
|
||||
expect((call![0].data as Record<string, unknown>)["contentLength"]).toBe(250);
|
||||
});
|
||||
|
||||
it("does not emit metadata.corrupt_detected for healthy JSON", () => {
|
||||
writeMetadata(dataDir, "ao-5", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
});
|
||||
mutateMetadata(dataDir, "ao-5", (existing) => ({ ...existing, summary: "hi" }));
|
||||
|
||||
const corruptCalls = vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.filter((c) => c[0].kind === "metadata.corrupt_detected");
|
||||
expect(corruptCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCanonicalLifecycle", () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from "../migration/storage-v2.js";
|
||||
import { readMetadata } from "../metadata.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 20_000 });
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = join(
|
||||
tmpdir(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCIFailureNotificationData,
|
||||
buildPRStateNotificationData,
|
||||
buildReactionEscalationNotificationData,
|
||||
buildReactionNotificationData,
|
||||
buildSessionTransitionNotificationData,
|
||||
getNotificationDataV3,
|
||||
semanticTypeForReactionKey,
|
||||
type NotificationEventContext,
|
||||
} from "../notification-data.js";
|
||||
|
||||
const context: NotificationEventContext = {
|
||||
pr: {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/app/pull/42",
|
||||
title: "Normalize notifier payloads",
|
||||
branch: "ao/notifier-payloads",
|
||||
baseBranch: "main",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
isDraft: false,
|
||||
},
|
||||
issueId: "AO-42",
|
||||
issueTitle: "Notifier payloads",
|
||||
summary: "Normalize notifier payloads",
|
||||
branch: "ao/notifier-payloads",
|
||||
};
|
||||
|
||||
describe("notification data v3", () => {
|
||||
it("builds semantic session transition data without legacy flat fields", () => {
|
||||
const data = buildSessionTransitionNotificationData({
|
||||
eventType: "session.needs_input",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
oldStatus: "working",
|
||||
newStatus: "needs_input",
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
semanticType: "session.needs_input",
|
||||
subject: {
|
||||
session: { id: "worker-1", projectId: "demo" },
|
||||
pr: { number: 42, url: "https://github.com/acme/app/pull/42" },
|
||||
issue: { id: "AO-42" },
|
||||
},
|
||||
transition: { kind: "session_status", from: "working", to: "needs_input" },
|
||||
});
|
||||
expect(data).not.toHaveProperty("context");
|
||||
expect(data).not.toHaveProperty("prUrl");
|
||||
expect(data).not.toHaveProperty("oldStatus");
|
||||
expect(data).not.toHaveProperty("newStatus");
|
||||
});
|
||||
|
||||
it("builds CI failure data with nested check details", () => {
|
||||
const data = buildCIFailureNotificationData({
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
failedChecks: [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/app/actions/runs/1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
semanticType: "ci.failing",
|
||||
ci: {
|
||||
status: "failing",
|
||||
failedChecks: [
|
||||
{
|
||||
name: "typecheck",
|
||||
status: "failed",
|
||||
conclusion: "FAILURE",
|
||||
url: "https://github.com/acme/app/actions/runs/1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(data).not.toHaveProperty("failedChecks", ["typecheck"]);
|
||||
});
|
||||
|
||||
it("maps reaction keys to semantic domain blocks", () => {
|
||||
const data = buildReactionNotificationData({
|
||||
eventType: "reaction.triggered",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
reactionKey: "approved-and-green",
|
||||
action: "notify",
|
||||
enrichment: {
|
||||
state: "open",
|
||||
ciStatus: "passing",
|
||||
reviewDecision: "approved",
|
||||
mergeable: true,
|
||||
hasConflicts: false,
|
||||
isBehind: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.semanticType).toBe("merge.ready");
|
||||
expect(data.reaction).toEqual({ key: "approved-and-green", action: "notify" });
|
||||
expect(data.ci).toEqual({ status: "passing" });
|
||||
expect(data.review).toEqual({ decision: "approved" });
|
||||
expect(data.merge).toMatchObject({ ready: true, conflicts: false, baseBranch: "main" });
|
||||
});
|
||||
|
||||
it("adds escalation details to reaction data", () => {
|
||||
const data = buildReactionEscalationNotificationData({
|
||||
eventType: "reaction.escalated",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
reactionKey: "ci-failed",
|
||||
action: "escalated",
|
||||
attempts: 4,
|
||||
cause: "max_retries",
|
||||
durationMs: 12_000,
|
||||
});
|
||||
|
||||
expect(data.semanticType).toBe("ci.failing");
|
||||
expect(data.escalation).toEqual({
|
||||
attempts: 4,
|
||||
cause: "max_retries",
|
||||
durationMs: 12_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds PR state transition data", () => {
|
||||
const data = buildPRStateNotificationData({
|
||||
eventType: "pr.closed",
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
oldPRState: "open",
|
||||
newPRState: "closed",
|
||||
});
|
||||
|
||||
expect(data.transition).toEqual({ kind: "pr_state", from: "open", to: "closed" });
|
||||
expect(data.subject.pr?.url).toBe("https://github.com/acme/app/pull/42");
|
||||
expect(data).not.toHaveProperty("oldPRState");
|
||||
expect(data).not.toHaveProperty("newPRState");
|
||||
});
|
||||
|
||||
it("recognizes only v3 notification data", () => {
|
||||
const data = buildCIFailureNotificationData({
|
||||
sessionId: "worker-1",
|
||||
projectId: "demo",
|
||||
context,
|
||||
failedChecks: [],
|
||||
});
|
||||
|
||||
expect(getNotificationDataV3(data)).toBe(data);
|
||||
expect(getNotificationDataV3({ schemaVersion: 2, prUrl: context.pr?.url })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to event type for unknown reaction keys", () => {
|
||||
expect(semanticTypeForReactionKey("custom-reaction", "reaction.triggered")).toBe(
|
||||
"reaction.triggered",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -159,6 +159,105 @@ describe("observability snapshot", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("uses YAML observability log level and stderr settings", () => {
|
||||
const originalLogLevel = process.env["AO_LOG_LEVEL"];
|
||||
const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"];
|
||||
delete process.env["AO_LOG_LEVEL"];
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
config.observability = { logLevel: "warn", stderr: true };
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
observer.recordOperation({
|
||||
metric: "spawn",
|
||||
operation: "notification.success-no-audit",
|
||||
outcome: "success",
|
||||
correlationId: "corr-info",
|
||||
projectId: "my-app",
|
||||
level: "info",
|
||||
});
|
||||
observer.recordOperation({
|
||||
metric: "notification_delivery",
|
||||
operation: "notification.deliver",
|
||||
outcome: "failure",
|
||||
correlationId: "corr-warn",
|
||||
projectId: "my-app",
|
||||
sessionId: "app-1",
|
||||
reason: "notifier target not found",
|
||||
level: "warn",
|
||||
});
|
||||
|
||||
const auditDir = join(getObservabilityBaseDir(config.configPath), "processes");
|
||||
const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson"));
|
||||
const auditLog = auditFiles
|
||||
.map((fileName) => readFileSync(join(auditDir, fileName), "utf-8"))
|
||||
.join("\n");
|
||||
|
||||
expect(auditLog).toContain('"operation":"notification.deliver"');
|
||||
expect(auditLog).not.toContain("notification.success-no-audit");
|
||||
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(stderrSpy.mock.calls[0]?.[0])).toContain('"operation":"notification.deliver"');
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env["AO_LOG_LEVEL"];
|
||||
} else {
|
||||
process.env["AO_LOG_LEVEL"] = originalLogLevel;
|
||||
}
|
||||
if (originalObservabilityStderr === undefined) {
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
} else {
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("lets observability env vars override YAML settings", () => {
|
||||
const originalLogLevel = process.env["AO_LOG_LEVEL"];
|
||||
const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"];
|
||||
process.env["AO_LOG_LEVEL"] = "info";
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = "0";
|
||||
config.observability = { logLevel: "error", stderr: true };
|
||||
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
|
||||
|
||||
try {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
observer.recordOperation({
|
||||
metric: "notification_delivery",
|
||||
operation: "notification.deliver",
|
||||
outcome: "success",
|
||||
correlationId: "corr-env",
|
||||
projectId: "my-app",
|
||||
sessionId: "app-1",
|
||||
level: "info",
|
||||
});
|
||||
|
||||
const auditDir = join(getObservabilityBaseDir(config.configPath), "processes");
|
||||
const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson"));
|
||||
const auditLog = auditFiles
|
||||
.map((fileName) => readFileSync(join(auditDir, fileName), "utf-8"))
|
||||
.join("\n");
|
||||
|
||||
expect(auditLog).toContain('"operation":"notification.deliver"');
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env["AO_LOG_LEVEL"];
|
||||
} else {
|
||||
process.env["AO_LOG_LEVEL"] = originalLogLevel;
|
||||
}
|
||||
if (originalObservabilityStderr === undefined) {
|
||||
delete process.env["AO_OBSERVABILITY_STDERR"];
|
||||
} else {
|
||||
process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts sensitive observability payload fields before persisting them", () => {
|
||||
const observer = createProjectObserver(config, "session-manager");
|
||||
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -348,6 +348,68 @@ describe("loadBuiltins", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("passes configPath for named notifier references without explicit config", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeDashboard = makePlugin("notifier", "dashboard");
|
||||
const cfg = makeOrchestratorConfig({
|
||||
configPath: "/test/config.yaml",
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "codex",
|
||||
workspace: "worktree",
|
||||
notifiers: ["dashboard"],
|
||||
},
|
||||
notificationRouting: {
|
||||
urgent: ["dashboard"],
|
||||
action: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
notifiers: {},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(cfg, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeDashboard.create).toHaveBeenCalledWith({
|
||||
configPath: "/test/config.yaml",
|
||||
});
|
||||
expect(
|
||||
registry.get<{ _config: Record<string, unknown> }>("notifier", "dashboard")?._config,
|
||||
).toEqual({
|
||||
configPath: "/test/config.yaml",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create an implicit notifier registration over a conflicting explicit entry", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeDashboard = makePlugin("notifier", "dashboard");
|
||||
const cfg = makeOrchestratorConfig({
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "codex",
|
||||
workspace: "worktree",
|
||||
notifiers: ["dashboard"],
|
||||
},
|
||||
notifiers: {
|
||||
dashboard: {
|
||||
plugin: "webhook",
|
||||
url: "https://hooks.example.com/dashboard",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await registry.loadBuiltins(cfg, async (pkg: string) => {
|
||||
if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeDashboard.create).not.toHaveBeenCalled();
|
||||
expect(registry.get("notifier", "dashboard")).toBeNull();
|
||||
});
|
||||
|
||||
it("strips package loading metadata from notifier config", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
const fakeWebhook = makePlugin("notifier", "webhook");
|
||||
|
|
@ -427,7 +489,7 @@ describe("loadBuiltins", () => {
|
|||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined);
|
||||
expect(fakeOpenClaw.create).not.toHaveBeenCalled();
|
||||
expect(fakeWebhook.create).toHaveBeenCalledWith({
|
||||
url: "http://127.0.0.1:8787/hook",
|
||||
retries: 3,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,635 @@
|
|||
/**
|
||||
* Regression tests for session-manager activity event instrumentation
|
||||
* (issue #1657 — extends PR #1620 to cover the rest of the failure paths
|
||||
* inside spawn / kill / restore / send / list).
|
||||
*
|
||||
* One test per MUST-class emit. Pattern follows the lifecycle-manager-
|
||||
* instrumentation tests: mock `recordActivityEvent`, drive the manager into
|
||||
* the failure path, then assert the right kind/level/data was logged.
|
||||
*
|
||||
* Invariants asserted by these tests (PR #1620 B1/B2 plus #1657 B25):
|
||||
* - state mutation happens BEFORE event emission
|
||||
* - failure-only emits — no event on a successful send/spawn
|
||||
* - cleanup-stack rollbacks emit per failed step (not in aggregate)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { writeMetadata, readMetadataRaw } from "../metadata.js";
|
||||
import { recordActivityEvent } from "../activity-events.js";
|
||||
import { getProjectWorktreesDir } from "../paths.js";
|
||||
import type { OrchestratorConfig, PluginRegistry, Agent } from "../types.js";
|
||||
import {
|
||||
setupTestContext,
|
||||
teardownTestContext,
|
||||
makeHandle,
|
||||
type TestContext,
|
||||
} from "./test-utils.js";
|
||||
|
||||
vi.mock("../activity-events.js", () => ({
|
||||
recordActivityEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
let ctx: TestContext;
|
||||
let sessionsDir: string;
|
||||
let mockRegistry: PluginRegistry;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = setupTestContext();
|
||||
({ sessionsDir, mockRegistry, config } = ctx);
|
||||
vi.mocked(recordActivityEvent).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardownTestContext(ctx);
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function findEvent(kind: string) {
|
||||
return vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.find((e) => e.kind === kind);
|
||||
}
|
||||
|
||||
function findAllEvents(kind: string) {
|
||||
return vi
|
||||
.mocked(recordActivityEvent)
|
||||
.mock.calls.map((c) => c[0])
|
||||
.filter((e) => e.kind === kind);
|
||||
}
|
||||
|
||||
function writeTerminatedSession(
|
||||
sessionId: string,
|
||||
options: { worktree: string; branch?: string },
|
||||
): void {
|
||||
const metadata: Record<string, unknown> = {
|
||||
worktree: options.worktree,
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (options.branch !== undefined) {
|
||||
metadata["branch"] = options.branch;
|
||||
}
|
||||
writeMetadata(sessionsDir, sessionId, metadata as unknown as Parameters<typeof writeMetadata>[2]);
|
||||
}
|
||||
|
||||
describe("session.kill_started (MUST)", () => {
|
||||
it("emits before runtime.destroy is attempted", async () => {
|
||||
let destroyCalled = false;
|
||||
let killStartedEmittedBeforeDestroy = false;
|
||||
|
||||
vi.mocked(ctx.mockRuntime.destroy).mockImplementation(async () => {
|
||||
destroyCalled = true;
|
||||
killStartedEmittedBeforeDestroy = !!findEvent("session.kill_started");
|
||||
});
|
||||
|
||||
writeMetadata(sessionsDir, "app-killed", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-killed");
|
||||
|
||||
expect(destroyCalled).toBe(true);
|
||||
expect(killStartedEmittedBeforeDestroy).toBe(true);
|
||||
|
||||
const start = findEvent("session.kill_started");
|
||||
expect(start).toMatchObject({
|
||||
projectId: "my-app",
|
||||
sessionId: "app-killed",
|
||||
source: "session-manager",
|
||||
kind: "session.kill_started",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit kill_started when session is already terminated (idempotent)", async () => {
|
||||
writeMetadata(sessionsDir, "app-already-killed", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "manual_kill_requested",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.kill("app-already-killed");
|
||||
|
||||
expect(findEvent("session.kill_started")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.spawn_failed — orchestrator path (MUST)", () => {
|
||||
it("emits session.spawned after a successful orchestrator spawn", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const session = await sm.spawnOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "be helpful",
|
||||
});
|
||||
|
||||
const events = findAllEvents("session.spawned");
|
||||
const orchestratorSpawned = events.find(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
);
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(orchestratorSpawned).toMatchObject({
|
||||
projectId: "my-app",
|
||||
sessionId: "app-orchestrator",
|
||||
source: "session-manager",
|
||||
kind: "session.spawned",
|
||||
summary: "spawned: app-orchestrator",
|
||||
data: {
|
||||
agent: "mock-agent",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
role: "orchestrator",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit terminal spawn_failed when ensure recovers a fixed reservation conflict", async () => {
|
||||
let releaseWorkspace: () => void = () => {};
|
||||
const blockingWorkspace = new Promise<void>((resolve) => {
|
||||
releaseWorkspace = resolve;
|
||||
});
|
||||
vi.mocked(ctx.mockWorkspace.create).mockImplementationOnce(async (cfg) => {
|
||||
await blockingWorkspace;
|
||||
return {
|
||||
path: join(ctx.tmpDir, "ws-orchestrator"),
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
});
|
||||
|
||||
const firstManager = createSessionManager({ config, registry: mockRegistry });
|
||||
const secondManager = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const firstEnsure = firstManager.ensureOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "be helpful",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.mockWorkspace.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const secondEnsure = secondManager.ensureOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "be helpful",
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(findEvent("session.orchestrator_conflict")).toBeDefined();
|
||||
});
|
||||
|
||||
releaseWorkspace();
|
||||
const [created, recovered] = await Promise.all([firstEnsure, secondEnsure]);
|
||||
|
||||
expect(created.id).toBe("app-orchestrator");
|
||||
expect(recovered.id).toBe("app-orchestrator");
|
||||
expect(findAllEvents("session.orchestrator_conflict")).toHaveLength(1);
|
||||
expect(
|
||||
findAllEvents("session.spawn_failed").filter(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits one terminal failure plus one stage failure when workspace.create throws", async () => {
|
||||
vi.mocked(ctx.mockWorkspace.create).mockRejectedValue(new Error("disk full"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(
|
||||
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
|
||||
).rejects.toThrow("disk full");
|
||||
|
||||
const events = findAllEvents("session.spawn_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
const orchestratorFailure = events.find(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
);
|
||||
expect(orchestratorFailure).toBeDefined();
|
||||
expect(orchestratorFailure!.level).toBe("error");
|
||||
expect(orchestratorFailure!.projectId).toBe("my-app");
|
||||
|
||||
const stepEvents = findAllEvents("session.spawn_step_failed");
|
||||
expect(stepEvents).toHaveLength(1);
|
||||
expect(stepEvents[0]!.sessionId).toBe("app-orchestrator");
|
||||
expect(stepEvents[0]!.data).toMatchObject({
|
||||
role: "orchestrator",
|
||||
stage: "workspace_create",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits one terminal failure plus one stage failure when runtime.create throws", async () => {
|
||||
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("tmux not found"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(
|
||||
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
|
||||
).rejects.toThrow("tmux not found");
|
||||
|
||||
const events = findAllEvents("session.spawn_failed");
|
||||
expect(events).toHaveLength(1);
|
||||
const orchestratorFailure = events.find(
|
||||
(e) => e.data && (e.data as Record<string, unknown>)["role"] === "orchestrator",
|
||||
);
|
||||
expect(orchestratorFailure).toBeDefined();
|
||||
expect(orchestratorFailure!.level).toBe("error");
|
||||
|
||||
const stepEvents = findAllEvents("session.spawn_step_failed");
|
||||
expect(stepEvents).toHaveLength(1);
|
||||
expect(stepEvents[0]!.sessionId).toBe("app-orchestrator");
|
||||
expect(stepEvents[0]!.data).toMatchObject({
|
||||
role: "orchestrator",
|
||||
stage: "runtime_create",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.rollback_started/session.rollback_step_failed (MUST)", () => {
|
||||
it("includes reserved sessionId when worker spawn rolls back after reservation", async () => {
|
||||
const workspacePath = join(getProjectWorktreesDir("my-app"), "app-1");
|
||||
vi.mocked(ctx.mockWorkspace.create).mockResolvedValue({
|
||||
path: workspacePath,
|
||||
branch: "feat/test",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
});
|
||||
vi.mocked(ctx.mockWorkspace.destroy).mockRejectedValue(new Error("destroy failed"));
|
||||
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("runtime failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("runtime failed");
|
||||
|
||||
const rollbackStarted = findEvent("session.rollback_started");
|
||||
expect(rollbackStarted).toBeDefined();
|
||||
expect(rollbackStarted!.sessionId).toBe("app-1");
|
||||
|
||||
const rollbackStepFailed = findEvent("session.rollback_step_failed");
|
||||
expect(rollbackStepFailed).toBeDefined();
|
||||
expect(rollbackStepFailed!.sessionId).toBe("app-1");
|
||||
expect(rollbackStepFailed!.data).toMatchObject({ reason: "destroy failed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.workspace_hooks_failed (MUST)", () => {
|
||||
it("emits when setupWorkspaceHooks throws during orchestrator spawn", async () => {
|
||||
const hookFailingAgent: Agent = {
|
||||
...ctx.mockAgent,
|
||||
name: "hook-failing-agent",
|
||||
setupWorkspaceHooks: vi.fn().mockRejectedValue(new Error("settings.json EACCES")),
|
||||
};
|
||||
const originalGet = mockRegistry.get;
|
||||
mockRegistry.get = vi.fn().mockImplementation((slot: string, name?: string) => {
|
||||
if (slot === "agent") return hookFailingAgent;
|
||||
return (originalGet as any)(slot, name);
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(
|
||||
sm.spawnOrchestrator({ projectId: "my-app", systemPrompt: "be helpful" }),
|
||||
).rejects.toThrow("settings.json EACCES");
|
||||
|
||||
const event = findEvent("session.workspace_hooks_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.projectId).toBe("my-app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime.lost_detected (MUST)", () => {
|
||||
it("emits when sm.list() persists runtime_lost for a dead runtime", async () => {
|
||||
writeMetadata(sessionsDir, "app-dead", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-dead"),
|
||||
});
|
||||
|
||||
// Runtime claims dead, agent process gone
|
||||
vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(false);
|
||||
vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(false);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await sm.list();
|
||||
|
||||
const event = findEvent("runtime.lost_detected");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.projectId).toBe("my-app");
|
||||
expect(event!.sessionId).toBe("app-dead");
|
||||
expect(event!.level).toBe("warn");
|
||||
|
||||
// B1: state mutation BEFORE event emission — verify metadata was persisted
|
||||
const persisted = readMetadataRaw(sessionsDir, "app-dead");
|
||||
expect(persisted).not.toBeNull();
|
||||
const lifecycleStr = persisted!["lifecycle"];
|
||||
expect(lifecycleStr).toBeDefined();
|
||||
const lc = JSON.parse(lifecycleStr!) as { session: { state: string; reason: string } };
|
||||
expect(lc.session.state).toBe("detecting");
|
||||
expect(lc.session.reason).toBe("runtime_lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.send_failed (MUST)", () => {
|
||||
it("emits after send retry-with-restore exhausts", async () => {
|
||||
writeMetadata(sessionsDir, "app-send", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(ctx.mockRuntime.sendMessage).mockRejectedValue(new Error("send broke"));
|
||||
// restore() throws so retry-with-restore exhausts
|
||||
vi.mocked(ctx.mockRuntime.create).mockRejectedValue(new Error("restore failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.send("app-send", "hi")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.send_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.sessionId).toBe("app-send");
|
||||
// B11: data must not contain message content
|
||||
expect(JSON.stringify(event!.data ?? {})).not.toContain("hi");
|
||||
});
|
||||
|
||||
it("does not double-emit session.restore_failed when restore fails during send", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-send-ws");
|
||||
writeTerminatedSession("app-send-restore", { worktree: wsPath, branch: "feat/send" });
|
||||
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("restore failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.send("app-send-restore", "hi")).rejects.toThrow();
|
||||
|
||||
const restoreFailed = findAllEvents("session.restore_failed");
|
||||
expect(restoreFailed).toHaveLength(1);
|
||||
expect(restoreFailed[0]!.data).toMatchObject({ stage: "workspace_restore" });
|
||||
expect(findEvent("session.send_failed")).toBeDefined();
|
||||
});
|
||||
|
||||
it("tags restore-for-delivery timeout restore_failed with ready_timeout stage", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const wsPath = join(ctx.tmpDir, "send-restore-ready-timeout");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
writeTerminatedSession("app-send-timeout", { worktree: wsPath, branch: "feat/send" });
|
||||
|
||||
vi.mocked(ctx.mockRuntime.isAlive).mockImplementation(async (handle) => {
|
||||
return handle.id !== "rt-restored";
|
||||
});
|
||||
vi.mocked(ctx.mockAgent.isProcessRunning).mockImplementation(async (handle) => {
|
||||
return handle.id !== "rt-restored";
|
||||
});
|
||||
vi.mocked(ctx.mockRuntime.create).mockResolvedValue(makeHandle("rt-restored"));
|
||||
vi.mocked(ctx.mockRuntime.getOutput).mockResolvedValue("");
|
||||
vi.mocked(ctx.mockAgent.detectActivity).mockReturnValue("idle");
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const sendPromise = sm.send("app-send-timeout", "hi");
|
||||
const rejection = expect(sendPromise).rejects.toThrow(
|
||||
"restored session did not become ready for delivery",
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await rejection;
|
||||
|
||||
const restoreFailed = findAllEvents("session.restore_failed");
|
||||
expect(restoreFailed).toHaveLength(1);
|
||||
expect(restoreFailed[0]!.data).toMatchObject({
|
||||
stage: "ready_timeout",
|
||||
reason: "restored session did not become ready for delivery",
|
||||
trigger: "send",
|
||||
});
|
||||
expect(findEvent("session.send_failed")).toBeDefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session.restore_failed (MUST)", () => {
|
||||
it("emits when restore's workspace restore throws", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-ws");
|
||||
writeMetadata(sessionsDir, "app-rest", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/x",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "terminated",
|
||||
reason: "manually_killed",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: "2025-01-01T00:00:00.000Z",
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "missing",
|
||||
reason: "process_missing",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: null,
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// workspace doesn't exist + restore throws
|
||||
vi.mocked(ctx.mockWorkspace.exists ?? (() => false)).mockResolvedValue?.(false);
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = vi.fn().mockRejectedValue(new Error("clone failed"));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-rest")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.sessionId).toBe("app-rest");
|
||||
});
|
||||
|
||||
it("emits SessionNotRestorableError when session is not restorable", async () => {
|
||||
writeMetadata(sessionsDir, "app-not-rest", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "feat/x",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
lifecycle: {
|
||||
version: 2,
|
||||
session: {
|
||||
kind: "worker",
|
||||
state: "working",
|
||||
reason: "task_in_progress",
|
||||
startedAt: "2025-01-01T00:00:00.000Z",
|
||||
completedAt: null,
|
||||
terminatedAt: null,
|
||||
lastTransitionAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
pr: { state: "none", reason: "not_created", number: null, url: null, lastObservedAt: null },
|
||||
runtime: {
|
||||
state: "alive",
|
||||
reason: "process_running",
|
||||
lastObservedAt: "2025-01-01T00:00:00.000Z",
|
||||
handle: makeHandle("rt-1"),
|
||||
tmuxName: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// active session — not restorable
|
||||
vi.mocked(ctx.mockRuntime.isAlive).mockResolvedValue(true);
|
||||
vi.mocked(ctx.mockAgent.isProcessRunning).mockResolvedValue(true);
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-not-rest")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits when workspace is missing and the workspace plugin cannot restore", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-no-restore");
|
||||
writeTerminatedSession("app-no-restore", { worktree: wsPath, branch: "feat/no-restore" });
|
||||
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = undefined;
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-no-restore")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.sessionId).toBe("app-no-restore");
|
||||
expect(event!.data).toMatchObject({
|
||||
stage: "workspace_restore",
|
||||
workspacePath: wsPath,
|
||||
reason: "workspace plugin does not support restore",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits when workspace is missing and branch metadata is absent", async () => {
|
||||
const wsPath = join(ctx.tmpDir, "missing-no-branch");
|
||||
writeTerminatedSession("app-no-branch", { worktree: wsPath });
|
||||
|
||||
ctx.mockWorkspace.exists = vi.fn().mockResolvedValue(false);
|
||||
ctx.mockWorkspace.restore = vi.fn().mockResolvedValue({
|
||||
path: wsPath,
|
||||
branch: "unused",
|
||||
sessionId: "app-no-branch",
|
||||
projectId: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
await expect(sm.restore("app-no-branch")).rejects.toThrow();
|
||||
|
||||
const event = findEvent("session.restore_failed");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.sessionId).toBe("app-no-branch");
|
||||
expect(event!.data).toMatchObject({
|
||||
stage: "workspace_restore",
|
||||
workspacePath: wsPath,
|
||||
reason: "branch metadata is missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata.corrupt_detected (MUST)", () => {
|
||||
it("emits when mutateMetadata side-renames a corrupt file", async () => {
|
||||
// Simulate a corrupt metadata file in the sessions dir
|
||||
const sessionPath = join(sessionsDir, "app-corrupt.json");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeFileSync(sessionPath, "{ this is not json", "utf-8");
|
||||
|
||||
const { mutateMetadata } = await import("../metadata.js");
|
||||
mutateMetadata(sessionsDir, "app-corrupt", () => ({ branch: "feat/x", project: "my-app" }), {
|
||||
createIfMissing: true,
|
||||
activityEventSource: "api",
|
||||
});
|
||||
|
||||
const event = findEvent("metadata.corrupt_detected");
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.level).toBe("error");
|
||||
expect(event!.source).toBe("api");
|
||||
expect(event!.sessionId).toBe("app-corrupt");
|
||||
const data = event!.data as Record<string, unknown>;
|
||||
expect(data["renameSucceeded"]).toBe(true);
|
||||
expect(data["renamedTo"]).toMatch(/\.corrupt-\d+$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { createSessionManager } from "../../session-manager.js";
|
|||
import {
|
||||
writeMetadata,
|
||||
readMetadataRaw,
|
||||
updateMetadata,
|
||||
} from "../../metadata.js";
|
||||
import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js";
|
||||
import type {
|
||||
|
|
@ -63,6 +64,47 @@ describe("list", () => {
|
|||
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
|
||||
});
|
||||
|
||||
it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: config.projects["my-app"]!.path,
|
||||
branch: "feat/a",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
});
|
||||
updateMetadata(sessionsDir, "app-1", { codexThreadId: "thread-1" });
|
||||
|
||||
const deadRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
isAlive: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const agentWithSessionInfo: Agent = {
|
||||
...mockAgent,
|
||||
name: "codex",
|
||||
getSessionInfo: vi.fn().mockResolvedValue({
|
||||
summary: null,
|
||||
agentSessionId: "rollout-1",
|
||||
metadata: { codexThreadId: "thread-1" },
|
||||
}),
|
||||
};
|
||||
const registryWithDeadRuntime: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return deadRuntime;
|
||||
if (slot === "agent") return agentWithSessionInfo;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
|
||||
await sm.list("my-app");
|
||||
await sm.list("my-app");
|
||||
|
||||
expect(agentWithSessionInfo.getSessionInfo).not.toHaveBeenCalled();
|
||||
expect(readMetadataRaw(sessionsDir, "app-1")!["codexThreadId"]).toBe("thread-1");
|
||||
});
|
||||
|
||||
it("does not backfill role onto foreign bare-id orchestrator records (issue #1048)", async () => {
|
||||
// Regression guard for PR #1075 review comment: a legacy record whose id
|
||||
// is `{projectId}-orchestrator` (pre-numbered scheme, wrong prefix) must
|
||||
|
|
@ -220,7 +262,9 @@ describe("list", () => {
|
|||
const sm = createSessionManager({ config, registry: registryWithDead });
|
||||
const sessions = await sm.list();
|
||||
|
||||
expect(sessions[0].status).toBe("killed");
|
||||
// sm.list() persists "detecting" (not "terminated") so the lifecycle
|
||||
// manager's probe pipeline makes the final terminal decision (#1735).
|
||||
expect(sessions[0].status).toBe("detecting");
|
||||
expect(sessions[0].activity).toBe("exited");
|
||||
});
|
||||
|
||||
|
|
@ -336,7 +380,8 @@ describe("list", () => {
|
|||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName);
|
||||
expect(sessions[0].status).toBe("killed");
|
||||
// sm.list() persists "detecting" so the lifecycle manager decides (#1735).
|
||||
expect(sessions[0].status).toBe("detecting");
|
||||
expect(sessions[0].activity).toBe("exited");
|
||||
expect(agentWithSpy.getActivityState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -575,7 +575,7 @@ describe("restore", () => {
|
|||
expect(meta!["restoreFallbackReason"]).toBe("mock-agent.getRestoreCommand returned null");
|
||||
});
|
||||
|
||||
it("does not launch a fresh chat when a native-restore agent cannot build restore command", async () => {
|
||||
it("falls back to a fresh launch when a native-restore agent cannot build restore command", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-native-restore-missing");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
|
|
@ -605,12 +605,119 @@ describe("restore", () => {
|
|||
|
||||
const sm = createSessionManager({ config, registry: registryWithNativeRestoreAgent });
|
||||
|
||||
await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError);
|
||||
expect(mockRuntime.create).not.toHaveBeenCalled();
|
||||
await sm.restore("app-1");
|
||||
|
||||
expect(mockRuntime.create).toHaveBeenCalled();
|
||||
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(createCall.launchCommand).toBe("mock-agent --start");
|
||||
const meta = readMetadataRaw(sessionsDir, "app-1");
|
||||
expect(meta!["restoreFallbackReason"]).toBe("codex.getRestoreCommand returned null");
|
||||
});
|
||||
|
||||
it("persists native restore metadata even when runtime is already dead", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-dead-runtime-metadata");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
||||
const deadRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
isAlive: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const agentWithDiscoverableThread: Agent = {
|
||||
...mockAgent,
|
||||
name: "codex",
|
||||
getSessionInfo: vi.fn().mockResolvedValue({
|
||||
summary: null,
|
||||
agentSessionId: "rollout-1",
|
||||
metadata: { codexThreadId: "thread-1" },
|
||||
}),
|
||||
getRestoreCommand: vi
|
||||
.fn()
|
||||
.mockImplementation(async (session) =>
|
||||
session.metadata?.codexThreadId ? `codex resume ${session.metadata.codexThreadId}` : null,
|
||||
),
|
||||
};
|
||||
|
||||
const registryWithDeadRuntime: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return deadRuntime;
|
||||
if (slot === "agent") return agentWithDiscoverableThread;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: wsPath,
|
||||
branch: "feat/TEST-1",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithDeadRuntime });
|
||||
const restored = await sm.restore("app-1");
|
||||
|
||||
expect(agentWithDiscoverableThread.getSessionInfo).toHaveBeenCalled();
|
||||
expect(agentWithDiscoverableThread.getRestoreCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ codexThreadId: "thread-1" }),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(restored.metadata["codexThreadId"]).toBe("thread-1");
|
||||
const createCall = (deadRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(createCall.launchCommand).toBe("codex resume thread-1");
|
||||
const meta = readMetadataRaw(sessionsDir, "app-1");
|
||||
expect(meta!["codexThreadId"]).toBe("thread-1");
|
||||
});
|
||||
|
||||
it("uses project path as restore workspace when worktree metadata is missing", async () => {
|
||||
mkdirSync(config.projects["my-app"]!.path, { recursive: true });
|
||||
|
||||
const agentWithWorkspaceAssertion: Agent = {
|
||||
...mockAgent,
|
||||
name: "codex",
|
||||
getRestoreCommand: vi
|
||||
.fn()
|
||||
.mockImplementation(async (session) =>
|
||||
session.workspacePath === config.projects["my-app"]!.path
|
||||
? "codex resume thread-1"
|
||||
: null,
|
||||
),
|
||||
};
|
||||
|
||||
const registryWithWorkspaceAssertion: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return agentWithWorkspaceAssertion;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "",
|
||||
branch: "feat/TEST-1",
|
||||
status: "killed",
|
||||
project: "my-app",
|
||||
runtimeHandle: makeHandle("rt-old"),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithWorkspaceAssertion });
|
||||
const restored = await sm.restore("app-1");
|
||||
|
||||
expect(restored.workspacePath).toBe(config.projects["my-app"]!.path);
|
||||
expect(agentWithWorkspaceAssertion.getRestoreCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspacePath: config.projects["my-app"]!.path }),
|
||||
expect.any(Object),
|
||||
);
|
||||
const createCall = (mockRuntime.create as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(createCall.workspacePath).toBe(config.projects["my-app"]!.path);
|
||||
expect(createCall.launchCommand).toBe("codex resume thread-1");
|
||||
});
|
||||
|
||||
it("clears restore fallback reason when getRestoreCommand succeeds", async () => {
|
||||
const wsPath = join(tmpDir, "ws-app-restore-clears-fallback");
|
||||
mkdirSync(wsPath, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
readMetadata,
|
||||
readMetadataRaw,
|
||||
} from "../../metadata.js";
|
||||
import { getProjectWorktreesDir } from "../../paths.js";
|
||||
import { getProjectDir, getProjectWorktreesDir } from "../../paths.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
|
|
@ -2489,4 +2489,183 @@ describe("spawn", () => {
|
|||
});
|
||||
|
||||
});
|
||||
|
||||
describe("relaunchOrchestrator", () => {
|
||||
it("spawns a fresh orchestrator when none exists", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(session.status).toBe("working");
|
||||
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("kills and replaces an existing orchestrator regardless of strategy", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const session = await sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(session.id).toBe("app-orchestrator");
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
|
||||
expect(mockWorkspace.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: "app-orchestrator" }),
|
||||
);
|
||||
const meta = readMetadataRaw(sessionsDir, "app-orchestrator");
|
||||
expect(meta?.["status"]).toBe("working");
|
||||
expect(meta?.["runtimeHandle"]).not.toContain("old-rt");
|
||||
});
|
||||
|
||||
it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => {
|
||||
const configWithIgnore: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
...config.projects,
|
||||
"my-app": {
|
||||
...config.projects["my-app"]!,
|
||||
orchestratorSessionStrategy: "ignore",
|
||||
},
|
||||
},
|
||||
};
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry });
|
||||
|
||||
await sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
|
||||
expect(mockWorkspace.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces concurrent relaunch calls into a single replacement", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const [s1, s2] = await Promise.all([
|
||||
sm.relaunchOrchestrator({ projectId: "my-app" }),
|
||||
sm.relaunchOrchestrator({ projectId: "my-app" }),
|
||||
]);
|
||||
|
||||
expect(s1.id).toBe("app-orchestrator");
|
||||
expect(s2.id).toBe("app-orchestrator");
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorkspace.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockRuntime.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
let releaseWorkspace: () => void = () => {};
|
||||
const blockingWorkspace = new Promise<void>((resolve) => {
|
||||
releaseWorkspace = resolve;
|
||||
});
|
||||
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
|
||||
await blockingWorkspace;
|
||||
return {
|
||||
path: join(tmpDir, "ws-relaunch"),
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
await Promise.resolve();
|
||||
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
|
||||
|
||||
releaseWorkspace();
|
||||
|
||||
const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]);
|
||||
expect(relaunched.id).toBe("app-orchestrator");
|
||||
expect(ensured.id).toBe("app-orchestrator");
|
||||
// Both should resolve to the *post-relaunch* session, not the killed one.
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt"));
|
||||
});
|
||||
|
||||
it("waits for an in-flight ensureOrchestrator before replacing", async () => {
|
||||
let releaseWorkspace: () => void = () => {};
|
||||
const blockingWorkspace = new Promise<void>((resolve) => {
|
||||
releaseWorkspace = resolve;
|
||||
});
|
||||
const ensureWorkspacePath = join(tmpDir, "ws-ensure");
|
||||
(mockWorkspace.create as ReturnType<typeof vi.fn>).mockImplementationOnce(async (cfg) => {
|
||||
await blockingWorkspace;
|
||||
return {
|
||||
path: ensureWorkspacePath,
|
||||
branch: cfg.branch,
|
||||
sessionId: cfg.sessionId,
|
||||
projectId: cfg.projectId,
|
||||
};
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" });
|
||||
// Yield so ensure can register itself in the in-flight map.
|
||||
await Promise.resolve();
|
||||
const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" });
|
||||
|
||||
releaseWorkspace();
|
||||
|
||||
const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]);
|
||||
|
||||
expect(ensured.id).toBe("app-orchestrator");
|
||||
expect(relaunched.id).toBe("app-orchestrator");
|
||||
// Relaunch's kill must run, destroying the runtime ensure just spawned.
|
||||
expect(mockRuntime.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites the orchestrator-prompt file with the new system prompt", async () => {
|
||||
writeMetadata(sessionsDir, "app-orchestrator", {
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
status: "working",
|
||||
branch: "orchestrator/app-orchestrator",
|
||||
worktree: join(tmpDir, "old-orchestrator-ws"),
|
||||
runtimeHandle: makeHandle("old-rt"),
|
||||
});
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
|
||||
await sm.relaunchOrchestrator({
|
||||
projectId: "my-app",
|
||||
systemPrompt: "FRESH ORCHESTRATOR PROMPT",
|
||||
});
|
||||
|
||||
const promptPath = join(
|
||||
getProjectDir("my-app"),
|
||||
"orchestrator-prompt-app-orchestrator.md",
|
||||
);
|
||||
expect(existsSync(promptPath)).toBe(true);
|
||||
expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager {
|
|||
.mockResolvedValue(
|
||||
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
|
||||
),
|
||||
relaunchOrchestrator: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }),
|
||||
),
|
||||
restore: vi.fn().mockResolvedValue(makeSession()),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
listCached: vi.fn().mockResolvedValue([]),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -20,14 +20,40 @@ export type ActivityEventSource =
|
|||
| "scm"
|
||||
| "runtime"
|
||||
| "agent"
|
||||
| "tracker"
|
||||
| "workspace"
|
||||
| "notifier"
|
||||
| "reaction"
|
||||
| "report-watcher";
|
||||
| "report-watcher"
|
||||
| "cli"
|
||||
| "config"
|
||||
| "plugin-registry"
|
||||
| "migration"
|
||||
| "recovery";
|
||||
|
||||
export type ActivityEventKind =
|
||||
| "session.spawn_started"
|
||||
| "session.spawned"
|
||||
| "session.spawn_failed"
|
||||
| "session.spawn_step_failed"
|
||||
| "session.killed"
|
||||
| "session.kill_started"
|
||||
| "session.send_failed"
|
||||
| "session.restore_failed"
|
||||
| "session.restore_fallback"
|
||||
| "session.rollback_started"
|
||||
| "session.rollback_step_failed"
|
||||
| "session.workspace_hooks_failed"
|
||||
| "session.cleanup_error"
|
||||
| "session.orchestrator_conflict"
|
||||
| "runtime.lost_detected"
|
||||
| "runtime.lost_persist_failed"
|
||||
| "runtime.destroy_failed"
|
||||
| "workspace.destroy_failed"
|
||||
| "agent.opencode_purge_failed"
|
||||
| "tracker.issue_fetch_failed"
|
||||
| "tracker.generate_prompt_failed"
|
||||
| "metadata.corrupt_detected"
|
||||
| "activity.transition"
|
||||
| "lifecycle.transition"
|
||||
| "ci.failing"
|
||||
|
|
@ -41,6 +67,20 @@ export type ActivityEventKind =
|
|||
| "runtime.probe_failed"
|
||||
| "agent.process_probe_failed"
|
||||
| "agent.activity_probe_failed"
|
||||
// Plugin-internal failure shapes (issue #1659)
|
||||
| "scm.gh_unavailable"
|
||||
| "scm.batch_enrich_pr_failed"
|
||||
| "scm.ci_summary_failclosed"
|
||||
| "workspace.post_create_failed"
|
||||
| "workspace.branch_collision"
|
||||
| "workspace.destroy_fell_back"
|
||||
| "workspace.corrupt_clone_skipped"
|
||||
| "tracker.dep_missing"
|
||||
| "tracker.api_timeout"
|
||||
| "notifier.auth_failed"
|
||||
| "notifier.unreachable"
|
||||
| "notifier.rate_limited"
|
||||
| "notifier.dep_missing"
|
||||
// Reaction lifecycle
|
||||
| "reaction.escalated"
|
||||
| "reaction.send_to_agent_failed"
|
||||
|
|
@ -51,8 +91,42 @@ export type ActivityEventKind =
|
|||
| "session.auto_cleanup_failed"
|
||||
| "lifecycle.poll_failed"
|
||||
| "detecting.escalated"
|
||||
// Notification delivery
|
||||
| "notification.delivery_failed"
|
||||
| "notification.target_missing"
|
||||
// Report watcher
|
||||
| "report_watcher.triggered";
|
||||
| "report_watcher.triggered"
|
||||
// Config/plugin-registry/storage migration
|
||||
| "config.project_resolve_failed"
|
||||
| "config.project_malformed"
|
||||
| "config.project_invalid"
|
||||
| "config.migrated"
|
||||
| "plugin-registry.load_failed"
|
||||
| "plugin-registry.validation_failed"
|
||||
| "plugin-registry.specifier_failed"
|
||||
| "migration.blocked"
|
||||
| "migration.project_failed"
|
||||
| "migration.rename_failed"
|
||||
| "migration.completed"
|
||||
| "migration.rollback_skipped"
|
||||
// Webhook ingress (api source)
|
||||
| "api.webhook_unverified"
|
||||
| "api.webhook_rejected"
|
||||
| "api.webhook_received"
|
||||
| "api.webhook_failed"
|
||||
// WebSocket terminal mux (ui source — Node-side server only)
|
||||
| "ui.terminal_connected"
|
||||
| "ui.terminal_disconnected"
|
||||
| "ui.terminal_heartbeat_lost"
|
||||
| "ui.terminal_pty_lost"
|
||||
| "ui.terminal_protocol_error"
|
||||
| "ui.session_broadcast_failed"
|
||||
// Recovery/forensic instrumentation
|
||||
| "recovery.session_failed"
|
||||
| "recovery.action_failed"
|
||||
| "api.agent_report.session_not_found"
|
||||
| "api.agent_report.transition_rejected"
|
||||
| "api.agent_report.apply_failed";
|
||||
|
||||
export type ActivityEventLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
|
|
@ -92,14 +166,12 @@ export function droppedEventCount(): number {
|
|||
}
|
||||
|
||||
function pruneOldEvents(db: ReturnType<typeof getDb>, cutoff: number): void {
|
||||
db
|
||||
?.prepare(
|
||||
`DELETE FROM activity_events
|
||||
db?.prepare(
|
||||
`DELETE FROM activity_events
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM activity_events WHERE ts_epoch < ? LIMIT ?
|
||||
)`,
|
||||
)
|
||||
.run(cutoff, PRUNE_BATCH_SIZE);
|
||||
).run(cutoff, PRUNE_BATCH_SIZE);
|
||||
}
|
||||
|
||||
// Patterns that indicate sensitive field names
|
||||
|
|
@ -116,7 +188,10 @@ function redactCredentialUrls(input: string): string {
|
|||
const proto = result.indexOf("://", offset);
|
||||
if (proto === -1) break;
|
||||
// Only match http:// or https:// (case-insensitive, matching old /gi flag)
|
||||
if (proto < 4) { offset = proto + 3; continue; }
|
||||
if (proto < 4) {
|
||||
offset = proto + 3;
|
||||
continue;
|
||||
}
|
||||
const schemeEnd = result.slice(Math.max(0, proto - 5), proto).toLowerCase();
|
||||
if (!schemeEnd.endsWith("http") && !schemeEnd.endsWith("https")) {
|
||||
offset = proto + 3;
|
||||
|
|
@ -127,7 +202,7 @@ function redactCredentialUrls(input: string): string {
|
|||
while (cursor < result.length) {
|
||||
const ch = result.charCodeAt(cursor);
|
||||
// Space/control chars or '/' mean no '@' is coming in userinfo
|
||||
if (ch <= 0x20 || ch === 0x2F) break;
|
||||
if (ch <= 0x20 || ch === 0x2f) break;
|
||||
if (ch === 0x40) {
|
||||
// '@' found — redact everything between :// and @
|
||||
// Lowercase the scheme to match the old /gi regex behavior
|
||||
|
|
@ -140,7 +215,11 @@ function redactCredentialUrls(input: string): string {
|
|||
cursor++;
|
||||
}
|
||||
// No '@' found — not a credential URL, move past this ://
|
||||
if (cursor >= result.length || result.charCodeAt(cursor) <= 0x20 || result.charCodeAt(cursor) === 0x2F) {
|
||||
if (
|
||||
cursor >= result.length ||
|
||||
result.charCodeAt(cursor) <= 0x20 ||
|
||||
result.charCodeAt(cursor) === 0x2f
|
||||
) {
|
||||
offset = proto + 3;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,8 @@ import { join, dirname } from "node:path";
|
|||
import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js";
|
||||
|
||||
/**
|
||||
* Maximum age (ms) for `waiting_input`/`blocked` entries before they're
|
||||
* considered stale. If no new terminal output overwrites the entry within
|
||||
* this window, the state falls through to downstream fallbacks instead of
|
||||
* keeping the session stuck in `needs_input` on the dashboard forever.
|
||||
* @deprecated Actionable states no longer decay on wallclock. Retained until
|
||||
* the activity-reducer cleanup removes the old activity-log module.
|
||||
*/
|
||||
export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
|
|
@ -129,7 +127,7 @@ export async function readLastActivityEntry(
|
|||
/**
|
||||
* Check the AO activity JSONL for actionable states only.
|
||||
*
|
||||
* Only returns `waiting_input`/`blocked` (with a staleness cap).
|
||||
* Only returns `waiting_input`/`blocked`.
|
||||
* Non-critical states (`active`, `ready`, `idle`) always return `null` so
|
||||
* callers fall through to their native signals (git commits, chat history,
|
||||
* API queries, native JSONL). This prevents the lifecycle manager's
|
||||
|
|
@ -144,18 +142,12 @@ export function checkActivityLogState(
|
|||
const { entry } = activityResult;
|
||||
|
||||
if (entry.state === "waiting_input" || entry.state === "blocked") {
|
||||
// Use the entry's own timestamp for staleness — not file mtime, which
|
||||
// gets refreshed every poll cycle by recordActivity and would prevent
|
||||
// stale entries from ever being detected.
|
||||
const entryTs = new Date(entry.ts);
|
||||
if (Number.isNaN(entryTs.getTime())) return null;
|
||||
const ageMs = Date.now() - entryTs.getTime();
|
||||
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
|
||||
return { state: entry.state, timestamp: entryTs };
|
||||
}
|
||||
return { state: entry.state, timestamp: entryTs };
|
||||
}
|
||||
|
||||
// Non-critical states and stale entries — fall through to native signals
|
||||
// Non-critical states fall through to native signals
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -178,16 +170,8 @@ export function getActivityFallbackState(
|
|||
const entryTs = new Date(entry.ts);
|
||||
if (Number.isNaN(entryTs.getTime())) return null;
|
||||
|
||||
// Actionable states use the same staleness cap as checkActivityLogState.
|
||||
// If the entry is stale, fall through to age-based decay instead of
|
||||
// re-surfacing a waiting_input/blocked that checkActivityLogState already filtered.
|
||||
if (entry.state === "waiting_input" || entry.state === "blocked") {
|
||||
const ageMs = Date.now() - entryTs.getTime();
|
||||
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
|
||||
return { state: entry.state, timestamp: entryTs };
|
||||
}
|
||||
// Stale actionable entry — treat as idle
|
||||
return { state: "idle", timestamp: entryTs };
|
||||
return { state: entry.state, timestamp: entryTs };
|
||||
}
|
||||
|
||||
// Age-based decay: active→ready→idle, but never promote past the
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type {
|
||||
CanonicalSessionLifecycle,
|
||||
CanonicalSessionReason,
|
||||
|
|
@ -26,8 +26,14 @@ import type {
|
|||
SessionId,
|
||||
SessionStatus,
|
||||
} from "./types.js";
|
||||
import { recordActivityEvent } from "./activity-events.js";
|
||||
import { mutateMetadata, readMetadataRaw } from "./metadata.js";
|
||||
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js";
|
||||
import {
|
||||
buildLifecycleMetadataPatch,
|
||||
cloneLifecycle,
|
||||
deriveLegacyStatus,
|
||||
parseCanonicalLifecycle,
|
||||
} from "./lifecycle-state.js";
|
||||
import { parsePrFromUrl } from "./utils/pr.js";
|
||||
import { assertValidSessionIdComponent } from "./utils/session-id.js";
|
||||
import { validateStatus } from "./utils/validation.js";
|
||||
|
|
@ -254,6 +260,11 @@ function buildAuditDir(dataDir: string): string {
|
|||
return join(dataDir, ".agent-report-audit");
|
||||
}
|
||||
|
||||
function inferProjectIdFromDataDir(dataDir: string): string | undefined {
|
||||
// dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering.
|
||||
return basename(dirname(dataDir)) || undefined;
|
||||
}
|
||||
|
||||
const AGENT_REPORT_AUDIT_MAX_BYTES = 256 * 1024;
|
||||
const AGENT_REPORT_AUDIT_MAX_ENTRIES = 200;
|
||||
|
||||
|
|
@ -383,8 +394,22 @@ export function applyAgentReport(
|
|||
sessionId: SessionId,
|
||||
input: ApplyAgentReportInput,
|
||||
): ApplyAgentReportResult {
|
||||
const projectId = inferProjectIdFromDataDir(dataDir);
|
||||
const raw = readMetadataRaw(dataDir, sessionId);
|
||||
if (!raw) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "api",
|
||||
kind: "api.agent_report.session_not_found",
|
||||
level: "warn",
|
||||
summary: `applyAgentReport: session not found: ${sessionId}`,
|
||||
data: {
|
||||
reportState: input.state,
|
||||
actor: input.actor,
|
||||
source: input.source,
|
||||
},
|
||||
});
|
||||
throw new Error(`Session not found: ${sessionId}`);
|
||||
}
|
||||
|
||||
|
|
@ -428,93 +453,124 @@ export function applyAgentReport(
|
|||
let legacyStatus: SessionStatus | null = null;
|
||||
let previousLegacyStatus: SessionStatus | null = null;
|
||||
|
||||
const nextMetadata = mutateMetadata(dataDir, sessionId, (existing) => {
|
||||
const current = cloneLifecycle(
|
||||
parseCanonicalLifecycle(existing, {
|
||||
sessionId,
|
||||
status: validateStatus(existing["status"]),
|
||||
}),
|
||||
);
|
||||
previousLegacyStatus = deriveLegacyStatus(current);
|
||||
before = buildAuditSnapshot(current, previousLegacyStatus);
|
||||
const validation = validateAgentReportTransition(current, input.state);
|
||||
if (!validation.ok) {
|
||||
appendAgentReportAuditEntry(dataDir, sessionId, {
|
||||
timestamp: now,
|
||||
actor,
|
||||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
accepted: false,
|
||||
rejectionReason: validation.reason ?? "transition rejected",
|
||||
before,
|
||||
after: before,
|
||||
});
|
||||
throw new Error(validation.reason ?? "transition rejected");
|
||||
}
|
||||
const mapped = mapAgentReportToLifecycle(input.state);
|
||||
previousState = current.session.state;
|
||||
nextState = mapped.sessionState;
|
||||
current.session.state = mapped.sessionState;
|
||||
current.session.reason = mapped.sessionReason;
|
||||
current.session.lastTransitionAt = now;
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
|
||||
const effectivePrNumber =
|
||||
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
|
||||
const canAdvancePrState =
|
||||
effectivePrUrl !== undefined ||
|
||||
effectivePrNumber !== undefined ||
|
||||
current.pr.state !== "none";
|
||||
if (canAdvancePrState) {
|
||||
current.pr.state = "open";
|
||||
current.pr.reason =
|
||||
input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
||||
current.pr.lastObservedAt = now;
|
||||
const nextMetadata = mutateMetadata(
|
||||
dataDir,
|
||||
sessionId,
|
||||
(existing) => {
|
||||
const current = cloneLifecycle(
|
||||
parseCanonicalLifecycle(existing, {
|
||||
sessionId,
|
||||
status: validateStatus(existing["status"]),
|
||||
}),
|
||||
);
|
||||
previousLegacyStatus = deriveLegacyStatus(current);
|
||||
before = buildAuditSnapshot(current, previousLegacyStatus);
|
||||
const validation = validateAgentReportTransition(current, input.state);
|
||||
if (!validation.ok) {
|
||||
const rejectionReason = validation.reason ?? "transition rejected";
|
||||
appendAgentReportAuditEntry(dataDir, sessionId, {
|
||||
timestamp: now,
|
||||
actor,
|
||||
source,
|
||||
reportState: input.state,
|
||||
note: trimmedNote,
|
||||
prNumber,
|
||||
prUrl: trimmedPrUrl,
|
||||
prIsDraft,
|
||||
accepted: false,
|
||||
rejectionReason,
|
||||
before,
|
||||
after: before,
|
||||
});
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "api",
|
||||
kind: "api.agent_report.transition_rejected",
|
||||
level: "warn",
|
||||
summary: `applyAgentReport rejected ${input.state} for ${sessionId}: ${rejectionReason}`,
|
||||
data: {
|
||||
reportState: input.state,
|
||||
rejectionReason,
|
||||
actor,
|
||||
reportSource: source,
|
||||
fromState: current.session.state,
|
||||
fromReason: current.session.reason,
|
||||
legacyStatus: previousLegacyStatus,
|
||||
},
|
||||
});
|
||||
throw new Error(rejectionReason);
|
||||
}
|
||||
if (effectivePrUrl) {
|
||||
current.pr.url = effectivePrUrl;
|
||||
const mapped = mapAgentReportToLifecycle(input.state);
|
||||
previousState = current.session.state;
|
||||
nextState = mapped.sessionState;
|
||||
current.session.state = mapped.sessionState;
|
||||
current.session.reason = mapped.sessionReason;
|
||||
current.session.lastTransitionAt = now;
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
const effectivePrUrl = trimmedPrUrl ?? current.pr.url ?? existingPrUrl;
|
||||
const effectivePrNumber =
|
||||
prNumber ?? current.pr.number ?? existingPrNumber ?? parsedPrFromUrl?.number;
|
||||
const canAdvancePrState =
|
||||
effectivePrUrl !== undefined ||
|
||||
effectivePrNumber !== undefined ||
|
||||
current.pr.state !== "none";
|
||||
if (canAdvancePrState) {
|
||||
current.pr.state = "open";
|
||||
current.pr.reason = input.state === "ready_for_review" ? "review_pending" : "in_progress";
|
||||
current.pr.lastObservedAt = now;
|
||||
}
|
||||
if (effectivePrUrl) {
|
||||
current.pr.url = effectivePrUrl;
|
||||
}
|
||||
if (effectivePrNumber !== undefined) {
|
||||
current.pr.number = effectivePrNumber;
|
||||
}
|
||||
}
|
||||
if (effectivePrNumber !== undefined) {
|
||||
current.pr.number = effectivePrNumber;
|
||||
if (mapped.sessionState === "working" && current.session.startedAt === null) {
|
||||
current.session.startedAt = now;
|
||||
}
|
||||
}
|
||||
if (mapped.sessionState === "working" && current.session.startedAt === null) {
|
||||
current.session.startedAt = now;
|
||||
}
|
||||
legacyStatus = deriveLegacyStatus(current);
|
||||
const next = { ...existing };
|
||||
Object.assign(
|
||||
next,
|
||||
buildLifecycleMetadataPatch(current),
|
||||
{
|
||||
legacyStatus = deriveLegacyStatus(current);
|
||||
const next = { ...existing };
|
||||
Object.assign(next, buildLifecycleMetadataPatch(current), {
|
||||
[AGENT_REPORT_METADATA_KEYS.STATE]: input.state,
|
||||
[AGENT_REPORT_METADATA_KEYS.AT]: now,
|
||||
},
|
||||
);
|
||||
if (trimmedNote) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
|
||||
} else {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
|
||||
}
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
if (trimmedPrUrl) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
|
||||
});
|
||||
if (trimmedNote) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = trimmedNote;
|
||||
} else {
|
||||
next[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
|
||||
}
|
||||
if (prNumber !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
|
||||
if (isPRWorkflowReport(input.state)) {
|
||||
if (trimmedPrUrl) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_URL] = trimmedPrUrl;
|
||||
}
|
||||
if (prNumber !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_NUMBER] = String(prNumber);
|
||||
}
|
||||
if (prIsDraft !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
|
||||
}
|
||||
}
|
||||
if (prIsDraft !== undefined) {
|
||||
next[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] = prIsDraft ? "true" : "false";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return next;
|
||||
},
|
||||
{ activityEventSource: "api" },
|
||||
);
|
||||
|
||||
if (!nextMetadata || !before || !previousState || !nextState || !legacyStatus) {
|
||||
recordActivityEvent({
|
||||
projectId,
|
||||
sessionId,
|
||||
source: "api",
|
||||
kind: "api.agent_report.apply_failed",
|
||||
level: "error",
|
||||
summary: `failed to apply agent report ${input.state} for ${sessionId}`,
|
||||
data: {
|
||||
reportState: input.state,
|
||||
actor,
|
||||
source,
|
||||
},
|
||||
});
|
||||
throw new Error(`Failed to apply agent report for session ${sessionId}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,504 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { atomicWriteFileSync } from "./atomic-write.js";
|
||||
import { getProjectCodeReviewsDir } from "./paths.js";
|
||||
|
||||
export type CodeReviewRunStatus =
|
||||
| "queued"
|
||||
| "preparing"
|
||||
| "running"
|
||||
| "needs_triage"
|
||||
| "sent_to_agent"
|
||||
| "waiting_update"
|
||||
| "clean"
|
||||
| "outdated"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export type CodeReviewFindingStatus = "open" | "dismissed" | "sent_to_agent" | "resolved";
|
||||
|
||||
export type CodeReviewSeverity = "info" | "warning" | "error";
|
||||
|
||||
export interface CodeReviewRun {
|
||||
id: string;
|
||||
projectId: string;
|
||||
linkedSessionId: string;
|
||||
reviewerSessionId: string;
|
||||
status: CodeReviewRunStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
targetSha?: string;
|
||||
baseSha?: string;
|
||||
prNumber?: number;
|
||||
prUrl?: string;
|
||||
reviewerWorkspacePath?: string;
|
||||
summary?: string;
|
||||
terminationReason?: string;
|
||||
}
|
||||
|
||||
export interface CodeReviewFinding {
|
||||
id: string;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
linkedSessionId: string;
|
||||
status: CodeReviewFindingStatus;
|
||||
severity: CodeReviewSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
filePath?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
category?: string;
|
||||
confidence?: number;
|
||||
fingerprint?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
dismissedAt?: string;
|
||||
dismissedBy?: string;
|
||||
sentToAgentAt?: string;
|
||||
}
|
||||
|
||||
export interface CodeReviewRunSummary extends CodeReviewRun {
|
||||
findingCount: number;
|
||||
openFindingCount: number;
|
||||
dismissedFindingCount: number;
|
||||
sentFindingCount: number;
|
||||
resolvedFindingCount: number;
|
||||
}
|
||||
|
||||
export interface CodeReviewStoreOptions {
|
||||
/** Override storage dir for tests. Defaults to the project code review store path. */
|
||||
storeDir?: string;
|
||||
}
|
||||
|
||||
export interface ListCodeReviewRunsFilter {
|
||||
linkedSessionId?: string;
|
||||
status?: CodeReviewRunStatus;
|
||||
}
|
||||
|
||||
export interface ListCodeReviewFindingsFilter {
|
||||
runId?: string;
|
||||
linkedSessionId?: string;
|
||||
status?: CodeReviewFindingStatus;
|
||||
}
|
||||
|
||||
export interface CreateCodeReviewRunInput {
|
||||
linkedSessionId: string;
|
||||
reviewerSessionId: string;
|
||||
status?: CodeReviewRunStatus;
|
||||
targetSha?: string;
|
||||
baseSha?: string;
|
||||
prNumber?: number;
|
||||
prUrl?: string;
|
||||
reviewerWorkspacePath?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface CreateCodeReviewFindingInput {
|
||||
runId: string;
|
||||
linkedSessionId: string;
|
||||
severity: CodeReviewSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
status?: CodeReviewFindingStatus;
|
||||
filePath?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
category?: string;
|
||||
confidence?: number;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
const REVIEW_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
||||
|
||||
function assertSafeReviewId(id: string, label: string): void {
|
||||
if (!id || id === "." || id === ".." || !REVIEW_ID_PATTERN.test(id)) {
|
||||
throw new Error(`Unsafe ${label}: "${id}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIsoTimestamp(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseRunStatus(value: unknown): CodeReviewRunStatus {
|
||||
switch (value) {
|
||||
case "queued":
|
||||
case "preparing":
|
||||
case "running":
|
||||
case "needs_triage":
|
||||
case "sent_to_agent":
|
||||
case "waiting_update":
|
||||
case "clean":
|
||||
case "outdated":
|
||||
case "failed":
|
||||
case "cancelled":
|
||||
return value;
|
||||
default:
|
||||
return "queued";
|
||||
}
|
||||
}
|
||||
|
||||
function parseFindingStatus(value: unknown): CodeReviewFindingStatus {
|
||||
switch (value) {
|
||||
case "dismissed":
|
||||
case "sent_to_agent":
|
||||
case "resolved":
|
||||
return value;
|
||||
case "open":
|
||||
default:
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
|
||||
function parseSeverity(value: unknown): CodeReviewSeverity {
|
||||
switch (value) {
|
||||
case "error":
|
||||
case "warning":
|
||||
case "info":
|
||||
return value;
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(path: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(path: string, value: unknown): void {
|
||||
atomicWriteFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function removeUndefined<T extends Record<string, unknown>>(value: T): T {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
|
||||
) as T;
|
||||
}
|
||||
|
||||
function compareUpdatedDesc(
|
||||
a: { updatedAt: string; createdAt: string; id: string },
|
||||
b: { updatedAt: string; createdAt: string; id: string },
|
||||
): number {
|
||||
return (
|
||||
Date.parse(b.updatedAt) - Date.parse(a.updatedAt) ||
|
||||
Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
|
||||
a.id.localeCompare(b.id)
|
||||
);
|
||||
}
|
||||
|
||||
function parseRun(projectId: string, value: unknown): CodeReviewRun | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = parseOptionalString(value["id"]);
|
||||
const linkedSessionId = parseOptionalString(value["linkedSessionId"]);
|
||||
const reviewerSessionId = parseOptionalString(value["reviewerSessionId"]);
|
||||
const createdAt = normalizeIsoTimestamp(value["createdAt"]);
|
||||
const updatedAt = normalizeIsoTimestamp(value["updatedAt"]) ?? createdAt;
|
||||
if (!id || !linkedSessionId || !reviewerSessionId || !createdAt || !updatedAt) return null;
|
||||
|
||||
return removeUndefined({
|
||||
id,
|
||||
projectId: parseOptionalString(value["projectId"]) ?? projectId,
|
||||
linkedSessionId,
|
||||
reviewerSessionId,
|
||||
status: parseRunStatus(value["status"]),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
startedAt: normalizeIsoTimestamp(value["startedAt"]),
|
||||
completedAt: normalizeIsoTimestamp(value["completedAt"]),
|
||||
targetSha: parseOptionalString(value["targetSha"]),
|
||||
baseSha: parseOptionalString(value["baseSha"]),
|
||||
prNumber: parseNumber(value["prNumber"]),
|
||||
prUrl: parseOptionalString(value["prUrl"]),
|
||||
reviewerWorkspacePath: parseOptionalString(value["reviewerWorkspacePath"]),
|
||||
summary: parseOptionalString(value["summary"]),
|
||||
terminationReason: parseOptionalString(value["terminationReason"]),
|
||||
});
|
||||
}
|
||||
|
||||
function parseFinding(projectId: string, value: unknown): CodeReviewFinding | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = parseOptionalString(value["id"]);
|
||||
const runId = parseOptionalString(value["runId"]);
|
||||
const linkedSessionId = parseOptionalString(value["linkedSessionId"]);
|
||||
const title = parseOptionalString(value["title"]);
|
||||
const body = parseOptionalString(value["body"]);
|
||||
const createdAt = normalizeIsoTimestamp(value["createdAt"]);
|
||||
const updatedAt = normalizeIsoTimestamp(value["updatedAt"]) ?? createdAt;
|
||||
if (!id || !runId || !linkedSessionId || !title || !body || !createdAt || !updatedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return removeUndefined({
|
||||
id,
|
||||
projectId: parseOptionalString(value["projectId"]) ?? projectId,
|
||||
runId,
|
||||
linkedSessionId,
|
||||
status: parseFindingStatus(value["status"]),
|
||||
severity: parseSeverity(value["severity"]),
|
||||
title,
|
||||
body,
|
||||
filePath: parseOptionalString(value["filePath"]),
|
||||
startLine: parseNumber(value["startLine"]),
|
||||
endLine: parseNumber(value["endLine"]),
|
||||
category: parseOptionalString(value["category"]),
|
||||
confidence: parseNumber(value["confidence"]),
|
||||
fingerprint: parseOptionalString(value["fingerprint"]),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
dismissedAt: normalizeIsoTimestamp(value["dismissedAt"]),
|
||||
dismissedBy: parseOptionalString(value["dismissedBy"]),
|
||||
sentToAgentAt: normalizeIsoTimestamp(value["sentToAgentAt"]),
|
||||
});
|
||||
}
|
||||
|
||||
export class CodeReviewStore {
|
||||
readonly projectId: string;
|
||||
readonly storeDir: string;
|
||||
|
||||
constructor(projectId: string, options: CodeReviewStoreOptions = {}) {
|
||||
this.projectId = projectId;
|
||||
this.storeDir = options.storeDir ?? getProjectCodeReviewsDir(projectId);
|
||||
}
|
||||
|
||||
get runsDir(): string {
|
||||
return join(this.storeDir, "runs");
|
||||
}
|
||||
|
||||
get findingsDir(): string {
|
||||
return join(this.storeDir, "findings");
|
||||
}
|
||||
|
||||
ensure(): void {
|
||||
mkdirSync(this.runsDir, { recursive: true });
|
||||
mkdirSync(this.findingsDir, { recursive: true });
|
||||
}
|
||||
|
||||
listRuns(filter: ListCodeReviewRunsFilter = {}): CodeReviewRun[] {
|
||||
return this.readAllRuns()
|
||||
.filter((run) => !filter.linkedSessionId || run.linkedSessionId === filter.linkedSessionId)
|
||||
.filter((run) => !filter.status || run.status === filter.status)
|
||||
.sort(compareUpdatedDesc);
|
||||
}
|
||||
|
||||
listRunSummaries(filter: ListCodeReviewRunsFilter = {}): CodeReviewRunSummary[] {
|
||||
const findings = this.listFindings();
|
||||
const countsByRun = new Map<string, Omit<CodeReviewRunSummary, keyof CodeReviewRun>>();
|
||||
|
||||
for (const finding of findings) {
|
||||
const counts = countsByRun.get(finding.runId) ?? {
|
||||
findingCount: 0,
|
||||
openFindingCount: 0,
|
||||
dismissedFindingCount: 0,
|
||||
sentFindingCount: 0,
|
||||
resolvedFindingCount: 0,
|
||||
};
|
||||
counts.findingCount++;
|
||||
if (finding.status === "open") counts.openFindingCount++;
|
||||
if (finding.status === "dismissed") counts.dismissedFindingCount++;
|
||||
if (finding.status === "sent_to_agent") counts.sentFindingCount++;
|
||||
if (finding.status === "resolved") counts.resolvedFindingCount++;
|
||||
countsByRun.set(finding.runId, counts);
|
||||
}
|
||||
|
||||
return this.listRuns(filter).map((run) => ({
|
||||
...run,
|
||||
...(countsByRun.get(run.id) ?? {
|
||||
findingCount: 0,
|
||||
openFindingCount: 0,
|
||||
dismissedFindingCount: 0,
|
||||
sentFindingCount: 0,
|
||||
resolvedFindingCount: 0,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
getRun(runId: string): CodeReviewRun | null {
|
||||
assertSafeReviewId(runId, "review run id");
|
||||
return parseRun(this.projectId, readJsonFile(this.runPath(runId)));
|
||||
}
|
||||
|
||||
createRun(input: CreateCodeReviewRunInput, now = new Date()): CodeReviewRun {
|
||||
const id = `review-run-${randomUUID()}`;
|
||||
const timestamp = now.toISOString();
|
||||
const run: CodeReviewRun = removeUndefined({
|
||||
id,
|
||||
projectId: this.projectId,
|
||||
linkedSessionId: input.linkedSessionId,
|
||||
reviewerSessionId: input.reviewerSessionId,
|
||||
status: input.status ?? "queued",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
startedAt: input.status === "running" ? timestamp : undefined,
|
||||
targetSha: input.targetSha,
|
||||
baseSha: input.baseSha,
|
||||
prNumber: input.prNumber,
|
||||
prUrl: input.prUrl,
|
||||
reviewerWorkspacePath: input.reviewerWorkspacePath,
|
||||
summary: input.summary,
|
||||
});
|
||||
this.writeRun(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
updateRun(
|
||||
runId: string,
|
||||
patch: Partial<Omit<CodeReviewRun, "id" | "projectId" | "createdAt">>,
|
||||
now = new Date(),
|
||||
): CodeReviewRun {
|
||||
const existing = this.getRun(runId);
|
||||
if (!existing) {
|
||||
throw new Error(`Code review run not found: ${runId}`);
|
||||
}
|
||||
const next = removeUndefined({
|
||||
...existing,
|
||||
...patch,
|
||||
id: existing.id,
|
||||
projectId: existing.projectId,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: now.toISOString(),
|
||||
});
|
||||
this.writeRun(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
listFindings(filter: ListCodeReviewFindingsFilter = {}): CodeReviewFinding[] {
|
||||
return this.readAllFindings()
|
||||
.filter((finding) => !filter.runId || finding.runId === filter.runId)
|
||||
.filter(
|
||||
(finding) => !filter.linkedSessionId || finding.linkedSessionId === filter.linkedSessionId,
|
||||
)
|
||||
.filter((finding) => !filter.status || finding.status === filter.status)
|
||||
.sort(compareUpdatedDesc);
|
||||
}
|
||||
|
||||
getFinding(findingId: string): CodeReviewFinding | null {
|
||||
assertSafeReviewId(findingId, "review finding id");
|
||||
return parseFinding(this.projectId, readJsonFile(this.findingPath(findingId)));
|
||||
}
|
||||
|
||||
createFinding(input: CreateCodeReviewFindingInput, now = new Date()): CodeReviewFinding {
|
||||
if (!this.getRun(input.runId)) {
|
||||
throw new Error(`Code review run not found: ${input.runId}`);
|
||||
}
|
||||
const id = `review-finding-${randomUUID()}`;
|
||||
const timestamp = now.toISOString();
|
||||
const finding: CodeReviewFinding = removeUndefined({
|
||||
id,
|
||||
projectId: this.projectId,
|
||||
runId: input.runId,
|
||||
linkedSessionId: input.linkedSessionId,
|
||||
status: input.status ?? "open",
|
||||
severity: input.severity,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
filePath: input.filePath,
|
||||
startLine: input.startLine,
|
||||
endLine: input.endLine,
|
||||
category: input.category,
|
||||
confidence: input.confidence,
|
||||
fingerprint: input.fingerprint,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
this.writeFinding(finding);
|
||||
return finding;
|
||||
}
|
||||
|
||||
updateFinding(
|
||||
findingId: string,
|
||||
patch: Partial<
|
||||
Omit<CodeReviewFinding, "id" | "projectId" | "runId" | "linkedSessionId" | "createdAt">
|
||||
>,
|
||||
now = new Date(),
|
||||
): CodeReviewFinding {
|
||||
const existing = this.getFinding(findingId);
|
||||
if (!existing) {
|
||||
throw new Error(`Code review finding not found: ${findingId}`);
|
||||
}
|
||||
const next = removeUndefined({
|
||||
...existing,
|
||||
...patch,
|
||||
id: existing.id,
|
||||
projectId: existing.projectId,
|
||||
runId: existing.runId,
|
||||
linkedSessionId: existing.linkedSessionId,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: now.toISOString(),
|
||||
});
|
||||
this.writeFinding(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
deleteAll(): void {
|
||||
rmSync(this.storeDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
private runPath(runId: string): string {
|
||||
assertSafeReviewId(runId, "review run id");
|
||||
return join(this.runsDir, `${runId}.json`);
|
||||
}
|
||||
|
||||
private findingPath(findingId: string): string {
|
||||
assertSafeReviewId(findingId, "review finding id");
|
||||
return join(this.findingsDir, `${findingId}.json`);
|
||||
}
|
||||
|
||||
private writeRun(run: CodeReviewRun): void {
|
||||
this.ensure();
|
||||
writeJsonFile(this.runPath(run.id), run);
|
||||
}
|
||||
|
||||
private writeFinding(finding: CodeReviewFinding): void {
|
||||
this.ensure();
|
||||
writeJsonFile(this.findingPath(finding.id), finding);
|
||||
}
|
||||
|
||||
private readAllRuns(): CodeReviewRun[] {
|
||||
return this.readAllJsonFiles(this.runsDir)
|
||||
.map((value) => parseRun(this.projectId, value))
|
||||
.filter((run): run is CodeReviewRun => run !== null);
|
||||
}
|
||||
|
||||
private readAllFindings(): CodeReviewFinding[] {
|
||||
return this.readAllJsonFiles(this.findingsDir)
|
||||
.map((value) => parseFinding(this.projectId, value))
|
||||
.filter((finding): finding is CodeReviewFinding => finding !== null);
|
||||
}
|
||||
|
||||
private readAllJsonFiles(dir: string): unknown[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map((entry) => readJsonFile(join(dir, entry.name)))
|
||||
.filter((value) => value !== null);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeReviewStore(
|
||||
projectId: string,
|
||||
options: CodeReviewStoreOptions = {},
|
||||
): CodeReviewStore {
|
||||
return new CodeReviewStore(projectId, options);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue