diff --git a/.changeset/config.json b/.changeset/config.json index 07ccb4343..0f83c3b95 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,6 +8,7 @@ "@aoagents/ao-core", "@aoagents/ao-cli", "@aoagents/ao", + "@aoagents/ao-notifier-macos", "@aoagents/ao-plugin-runtime-tmux", "@aoagents/ao-plugin-runtime-process", "@aoagents/ao-plugin-agent-claude-code", @@ -27,6 +28,7 @@ "@aoagents/ao-plugin-notifier-slack", "@aoagents/ao-plugin-notifier-webhook", "@aoagents/ao-plugin-notifier-composio", + "@aoagents/ao-plugin-notifier-dashboard", "@aoagents/ao-plugin-notifier-discord", "@aoagents/ao-plugin-notifier-openclaw", "@aoagents/ao-plugin-terminal-iterm2", diff --git a/.changeset/notifier-release-links.md b/.changeset/notifier-release-links.md new file mode 100644 index 000000000..24ffdf344 --- /dev/null +++ b/.changeset/notifier-release-links.md @@ -0,0 +1,14 @@ +--- +"@aoagents/ao-cli": patch +"@aoagents/ao-core": patch +"@aoagents/ao-web": patch +"@aoagents/ao-notifier-macos": patch +"@aoagents/ao-plugin-notifier-composio": patch +"@aoagents/ao-plugin-notifier-dashboard": patch +"@aoagents/ao-plugin-notifier-desktop": patch +"@aoagents/ao-plugin-notifier-discord": patch +"@aoagents/ao-plugin-notifier-openclaw": patch +"@aoagents/ao-plugin-notifier-slack": patch +--- + +Add the notifier test harness, dashboard notifications, and desktop notifier setup. diff --git a/.issue-assets/1736-notifier-logging.png b/.issue-assets/1736-notifier-logging.png new file mode 100644 index 000000000..e6c09b5b1 Binary files /dev/null and b/.issue-assets/1736-notifier-logging.png differ diff --git a/docs/CLI.md b/docs/CLI.md index ab4bae5c7..27411c11a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -12,6 +12,9 @@ ao stop # Stop everything (dashboard, orchestrato ao status # Overview of all sessions ao status --watch # Live-updating terminal status view ao dashboard # Open web dashboard in browser +ao setup dashboard # Configure dashboard notification retention/routing +ao setup desktop # Install/configure native macOS desktop notifications +ao notify test --to desktop # Send a manual notifier test without starting AO ao completion zsh # Print the zsh completion script ``` @@ -42,6 +45,7 @@ ao session restore # Revive a crashed agent ```bash ao doctor # Check install, runtime, and stale temp issues ao doctor --fix # Apply safe fixes automatically +ao setup openclaw # Connect AO notifications to OpenClaw ao update # Update local AO install (source installs only) ao config-help # Show full config schema reference ``` diff --git a/docs/observability.md b/docs/observability.md index f9f061592..746ee012a 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -10,9 +10,13 @@ This document describes runtime observability emitted by Agent Orchestrator. ## Emission Model -- **Structured logs**: JSON lines on stderr, controlled by `AO_LOG_LEVEL`. +- **Structured logs**: JSON lines on stderr when enabled by config or `AO_OBSERVABILITY_STDERR`. - Supported levels: `debug`, `info`, `warn`, `error`. - Default level: `warn` (production-safe, avoids high-volume info logs). + - Default stderr mirroring: disabled. + - Runtime env vars override YAML: + - `AO_LOG_LEVEL=info` + - `AO_OBSERVABILITY_STDERR=1` - **Durable snapshots**: process-local JSON snapshots under: - `~/.agent-orchestrator/{config-hash}-observability/processes/*.json` - **Aggregated view**: merged by project via: @@ -38,6 +42,7 @@ Counters are emitted per project and operation: - `lifecycle_poll` (`lifecycle.merge_cleanup.completed`) — auto-cleanup ran after a PR was detected as merged; session runtime + worktree + metadata were torn down - `lifecycle_poll` (`lifecycle.merge_cleanup.deferred`) — auto-cleanup is waiting for the agent to idle (or for the `mergeCleanupIdleGraceMs` window to elapse) before tearing down - `lifecycle_poll` (`lifecycle.merge_cleanup.failed`) — auto-cleanup threw during `sessionManager.kill()`; the session stays in `merged` so the next poll retries +- `notification_delivery` (`notification.deliver`) — per-target notifier dispatch success/failure, with event ID/type, priority, project/session IDs, target reference, plugin name, and delivery method - `api_request` (web API routes) - `sse_connect`, `sse_snapshot`, `sse_disconnect` - `websocket_connect`, `websocket_disconnect`, `websocket_error` (websocket servers) @@ -83,10 +88,11 @@ Health records provide current status and failure context per surface: - **Dashboard**: use **Copy debug info** in the hero toolbar (desktop) to copy `/api/observability` plus page URL, project scope, and correlation id to the clipboard for issue reports. The observability banner shows overall status, SSE stream state, last correlation id, and latest failure reason. - **API**: `/api/observability` returns merged per-project diagnostics (`overallStatus`, metrics, health, recent traces, session state). - **Terminal websocket health**: `/health` endpoints include active sessions and websocket/terminal health counters with last error/disconnect reasons. +- **Notifications**: successful deliveries update `notification_delivery` metrics and per-target health; failed/missing targets also appear in `ao events`. ## Rollout Notes -1. Deploy with default `AO_LOG_LEVEL=warn` to avoid noisy logs. +1. Deploy with default `observability.logLevel: warn` and `observability.stderr: false` to avoid noisy logs. 2. Validate `/api/observability` and dashboard banner in a canary environment. -3. If deeper triage is needed, temporarily raise `AO_LOG_LEVEL=info` (or `debug`), then revert to `warn`. +3. If deeper triage is needed, temporarily raise `AO_LOG_LEVEL=info` (or `debug`) and set `AO_OBSERVABILITY_STDERR=1`, then revert to defaults. 4. Monitor `lastFailureReason` and surface-level `reason` fields before enabling broader rollout. diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index 5e1663767..5c2f7ce8e 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -6,6 +6,8 @@ const { mockFindConfigFile, mockLoadConfig, mockCreatePluginRegistry, + mockCreateProjectObserver, + mockRecordNotificationDelivery, mockDetectOpenClawInstallation, mockValidateToken, mockRegistry, @@ -18,6 +20,8 @@ const { mockFindConfigFile: vi.fn(), mockLoadConfig: vi.fn(), mockCreatePluginRegistry: vi.fn(), + mockCreateProjectObserver: vi.fn(), + mockRecordNotificationDelivery: vi.fn(), mockDetectOpenClawInstallation: vi.fn(), mockValidateToken: vi.fn(), mockRegistry: { @@ -36,10 +40,16 @@ vi.mock("../../src/lib/script-runner.js", () => ({ })); vi.mock("@aoagents/ao-core", () => ({ + buildCIFailureNotificationData: () => ({ schemaVersion: 3 }), + buildPRStateNotificationData: () => ({ schemaVersion: 3 }), + buildReactionNotificationData: () => ({ schemaVersion: 3 }), + buildSessionTransitionNotificationData: () => ({ schemaVersion: 3 }), createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args), + createProjectObserver: (...args: unknown[]) => mockCreateProjectObserver(...args), findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), getObservabilityBaseDir: () => "/tmp/.agent-orchestrator/observability", loadConfig: (...args: unknown[]) => mockLoadConfig(...args), + recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args), resolveNotifierTarget: ( config: { notifiers?: Record }, reference: string, @@ -157,6 +167,12 @@ describe("doctor command", () => { mockCreatePluginRegistry.mockReset(); mockCreatePluginRegistry.mockReturnValue(mockRegistry); + mockCreateProjectObserver.mockReset(); + mockCreateProjectObserver.mockReturnValue({ + recordOperation: vi.fn(), + setHealth: vi.fn(), + }); + mockRecordNotificationDelivery.mockReset(); mockRegistry.loadFromConfig.mockReset(); mockRegistry.loadFromConfig.mockResolvedValue(undefined); diff --git a/packages/cli/__tests__/commands/notify.test.ts b/packages/cli/__tests__/commands/notify.test.ts new file mode 100644 index 000000000..2f0d94f61 --- /dev/null +++ b/packages/cli/__tests__/commands/notify.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; + +const { + mockCreatePluginRegistry, + mockCreateProjectObserver, + mockFindConfigFile, + mockLoadConfig, + mockRecordNotificationDelivery, + mockRegistry, +} = vi.hoisted(() => ({ + mockCreatePluginRegistry: vi.fn(), + mockCreateProjectObserver: vi.fn(), + mockFindConfigFile: vi.fn(), + mockLoadConfig: vi.fn(), + mockRecordNotificationDelivery: vi.fn(), + mockRegistry: { + loadFromConfig: vi.fn(), + get: vi.fn(), + list: vi.fn(), + register: vi.fn(), + loadBuiltins: vi.fn(), + }, + })); + +vi.mock("@aoagents/ao-core", () => { + function buildSubject(input: { + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + }) { + return { + session: { id: input.sessionId, projectId: input.projectId }, + ...(input.context?.pr ? { pr: input.context.pr } : {}), + }; + } + + function baseData(input: { + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + semanticType?: string; + }) { + return { + schemaVersion: 3, + semanticType: input.semanticType, + subject: buildSubject(input), + }; + } + + return { + createPluginRegistry: (...args: unknown[]) => mockCreatePluginRegistry(...args), + createProjectObserver: (...args: unknown[]) => mockCreateProjectObserver(...args), + findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), + recordNotificationDelivery: (...args: unknown[]) => mockRecordNotificationDelivery(...args), + resolveNotifierTarget: ( + config: { notifiers?: Record }, + reference: string, + ) => ({ + reference, + pluginName: config.notifiers?.[reference]?.plugin ?? reference, + }), + buildCIFailureNotificationData: (input: { + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + failedChecks: Array>; + }) => ({ + ...baseData({ ...input, semanticType: "ci.failing" }), + ci: { status: "failing", failedChecks: input.failedChecks }, + }), + buildPRStateNotificationData: (input: { + eventType: string; + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + oldPRState: string; + newPRState: string; + }) => ({ + ...baseData({ ...input, semanticType: input.eventType }), + transition: { kind: "pr_state", from: input.oldPRState, to: input.newPRState }, + }), + buildReactionNotificationData: (input: { + eventType: string; + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + reactionKey: string; + action: string; + }) => ({ + ...baseData({ + ...input, + semanticType: + input.reactionKey === "all-complete" ? "summary.all_complete" : input.eventType, + }), + reaction: { key: input.reactionKey, action: input.action }, + }), + buildSessionTransitionNotificationData: (input: { + eventType: string; + sessionId: string; + projectId: string; + context?: { pr?: Record | null }; + oldStatus: string; + newStatus: string; + }) => ({ + ...baseData({ ...input, semanticType: input.eventType }), + transition: { kind: "session_status", from: input.oldStatus, to: input.newStatus }, + }), + }; +}); + +vi.mock("../../src/lib/plugin-store.js", () => ({ + importPluginModuleFromSource: vi.fn(), +})); + +import { registerNotify } from "../../src/commands/notify.js"; + +function makeConfig() { + return { + configPath: "/tmp/agent-orchestrator.yaml", + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["alerts"], + }, + projects: { + demo: { + name: "Demo", + path: "/tmp/demo", + defaultBranch: "main", + sessionPrefix: "demo", + }, + }, + notifiers: { + alerts: { plugin: "slack" }, + }, + notificationRouting: { + urgent: ["alerts"], + action: ["alerts"], + warning: ["alerts"], + info: ["alerts"], + }, + reactions: {}, + }; +} + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + registerNotify(program); + return program; +} + +describe("notify command", () => { + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let processExitSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + + mockFindConfigFile.mockReset(); + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockLoadConfig.mockReset(); + mockLoadConfig.mockReturnValue(makeConfig()); + mockCreatePluginRegistry.mockReset(); + mockCreatePluginRegistry.mockReturnValue(mockRegistry); + mockCreateProjectObserver.mockReset(); + mockCreateProjectObserver.mockReturnValue({ + recordOperation: vi.fn(), + setHealth: vi.fn(), + }); + mockRecordNotificationDelivery.mockReset(); + mockRegistry.loadFromConfig.mockReset(); + mockRegistry.loadFromConfig.mockResolvedValue(undefined); + mockRegistry.get.mockReset(); + mockRegistry.list.mockReset(); + mockRegistry.register.mockReset(); + mockRegistry.loadBuiltins.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves a dry run without sending", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + mockRegistry.get.mockReturnValue({ name: "alerts", notify }); + + await createProgram().parseAsync(["node", "test", "notify", "test", "--dry-run"]); + + expect(mockRegistry.loadFromConfig).toHaveBeenCalledWith(makeConfig(), expect.any(Function)); + expect(notify).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain("Dry run"); + }); + + it("sends template data and valid --data overrides", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + mockRegistry.get.mockReturnValue({ name: "alerts", notify }); + + await createProgram().parseAsync([ + "node", + "test", + "notify", + "test", + "--template", + "ci-failing", + "--data", + '{"runId":"123"}', + ]); + + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0]).toMatchObject({ + type: "ci.failing", + priority: "action", + data: { + schemaVersion: 3, + subject: { + pr: { + number: 1579, + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + }, + }, + ci: { + status: "failing", + failedChecks: [ + { name: "typecheck", status: "failed" }, + { name: "unit-tests", status: "failed" }, + ], + }, + runId: "123", + }, + }); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it("exits 1 for invalid --data JSON", async () => { + await expect( + createProgram().parseAsync(["node", "test", "notify", "test", "--data", "{bad"]), + ).rejects.toThrow("process.exit(1)"); + + expect(consoleErrorSpy.mock.calls.map((call) => call.join(" ")).join("\n")).toContain( + "Invalid --data JSON", + ); + }); + + it("captures one sink webhook payload and closes cleanly", async () => { + let sinkUrl = ""; + + mockRegistry.loadFromConfig.mockImplementation( + (config: { notifiers: Record }) => { + sinkUrl = config.notifiers.sink?.url ?? ""; + }, + ); + mockRegistry.get.mockImplementation((slot: string, name: string) => { + if (slot !== "notifier" || name !== "sink") return null; + return { + name: "sink", + notify: async (event: unknown) => { + await fetch(sinkUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "notification", event }), + }); + }, + }; + }); + + await createProgram().parseAsync(["node", "test", "notify", "test", "--sink"]); + + const output = consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n"); + expect(output).toContain("Sink received"); + expect(output).toContain("Test notification from ao notify test"); + expect(processExitSpy).not.toHaveBeenCalled(); + + await expect(fetch(sinkUrl, { method: "POST", body: "{}" })).rejects.toThrow(); + }); + + it("does not start a sink delivery in dry-run mode", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + mockRegistry.get.mockImplementation((slot: string, name: string) => { + if (slot === "notifier" && name === "sink") { + return { name: "sink", notify }; + } + return null; + }); + + await createProgram().parseAsync(["node", "test", "notify", "test", "--sink", "--dry-run"]); + + expect(notify).not.toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls.map((call) => call.join(" ")).join("\n")).not.toContain( + "Sink received", + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index 3b043e88e..3c8155e33 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -12,11 +12,24 @@ const { mockFindConfigFile } = vi.hoisted(() => ({ mockFindConfigFile: vi.fn(), })); -const { mockReadFileSync, mockWriteFileSync, mockExistsSync, mockMkdirSync } = vi.hoisted(() => ({ +const { + mockReadFileSync, + mockWriteFileSync, + mockExistsSync, + mockMkdirSync, + mockCpSync, + mockRmSync, +} = vi.hoisted(() => ({ mockReadFileSync: vi.fn(), mockWriteFileSync: vi.fn(), mockExistsSync: vi.fn(), mockMkdirSync: vi.fn(), + mockCpSync: vi.fn(), + mockRmSync: vi.fn(), +})); + +const { mockExecFileSync } = vi.hoisted(() => ({ + mockExecFileSync: vi.fn(), })); const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = vi.hoisted(() => ({ @@ -25,12 +38,81 @@ const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = mockDetectOpenClawInstallation: vi.fn(), })); +const { + mockComposioConstructorOptions, + mockAuthConfigsList, + mockAuthConfigsCreate, + mockAuthConfigsRetrieve, + mockConnectedAccountsList, + mockConnectedAccountsGet, + mockConnectedAccountsLink, + mockConnectedAccountsInitiate, + mockConnectedAccountsWaitForConnection, + mockToolkitsAuthorize, +} = vi.hoisted(() => ({ + mockComposioConstructorOptions: [] as Array>, + mockAuthConfigsList: vi.fn(), + mockAuthConfigsCreate: vi.fn(), + mockAuthConfigsRetrieve: vi.fn(), + mockConnectedAccountsList: vi.fn(), + mockConnectedAccountsGet: vi.fn(), + mockConnectedAccountsLink: vi.fn(), + mockConnectedAccountsInitiate: vi.fn(), + mockConnectedAccountsWaitForConnection: vi.fn(), + mockToolkitsAuthorize: vi.fn(), +})); + +const { mockFetch } = vi.hoisted(() => ({ + mockFetch: vi.fn(), +})); + +const { mockClack } = vi.hoisted(() => ({ + mockClack: { + cancel: vi.fn(), + confirm: vi.fn(), + intro: vi.fn(), + isCancel: vi.fn(), + log: Object.assign(vi.fn(), { success: vi.fn(), warn: vi.fn() }), + outro: vi.fn(), + password: vi.fn(), + select: vi.fn(), + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + text: vi.fn(), + }, +})); + +function testHttpsUrl(hostParts: string[], path: string): string { + return `https://${hostParts.join(".")}${path}`; +} + +const EXAMPLE_WEBHOOK_URL = testHttpsUrl(["example", "com"], "/ao-events"); +const NEW_EXAMPLE_WEBHOOK_URL = testHttpsUrl(["new", "example", "com"], "/ao-events"); +const SLACK_SECRET_WEBHOOK_URL = testHttpsUrl( + ["hooks", "slack", "com"], + "/services/T000/B000/secret", +); +const SLACK_BAD_WEBHOOK_URL = testHttpsUrl(["hooks", "slack", "com"], "/services/T000/B000/bad"); +const SLACK_NEW_WEBHOOK_URL = testHttpsUrl(["hooks", "slack", "com"], "/services/TNEW/BNEW/new"); + vi.mock("@aoagents/ao-core", () => ({ CONFIG_SCHEMA_URL: "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT: 50, findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + getDashboardNotificationStorePath: (configPath: string) => + `${configPath}.dashboard-notifications.jsonl`, isCanonicalGlobalConfigPath: (configPath: string | undefined) => configPath === join(homedir(), ".agent-orchestrator", "config.yaml"), + normalizeDashboardNotificationLimit: (value: unknown) => { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number.parseInt(value, 10) + : 50; + return Number.isFinite(parsed) ? Math.min(500, Math.max(1, Math.floor(parsed))) : 50; + }, + readDashboardNotificationsFromFile: () => [], recordActivityEvent: vi.fn(), })); @@ -42,6 +124,16 @@ vi.mock("node:fs", async (importOriginal) => { writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), existsSync: (...args: unknown[]) => mockExistsSync(...args), mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), + cpSync: (...args: unknown[]) => mockCpSync(...args), + rmSync: (...args: unknown[]) => mockRmSync(...args), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), }; }); @@ -53,8 +145,36 @@ vi.mock("../../src/lib/openclaw-probe.js", () => ({ HOOKS_PATH: "/hooks/agent", })); +vi.mock("@composio/core", () => { + function MockComposio(opts: Record) { + mockComposioConstructorOptions.push(opts); + return { + authConfigs: { + list: mockAuthConfigsList, + create: mockAuthConfigsCreate, + get: mockAuthConfigsRetrieve, + retrieve: mockAuthConfigsRetrieve, + }, + connectedAccounts: { + list: mockConnectedAccountsList, + get: mockConnectedAccountsGet, + link: mockConnectedAccountsLink, + initiate: mockConnectedAccountsInitiate, + waitForConnection: mockConnectedAccountsWaitForConnection, + }, + toolkits: { + authorize: mockToolkitsAuthorize, + }, + }; + } + return { Composio: MockComposio }; +}); + +vi.mock("@clack/prompts", () => mockClack); + import { recordActivityEvent } from "@aoagents/ao-core"; import { registerSetup } from "../../src/commands/setup.js"; +import { applyNotifierRoutingPreset } from "../../src/lib/notifier-routing.js"; // --------------------------------------------------------------------------- // Helpers @@ -92,13 +212,1827 @@ function createProgram(): Command { return program; } -const recordedEvents = (): Array> => - vi.mocked(recordActivityEvent).mock.calls.map((c) => c[0] as Record); - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- +describe("notifier routing helpers", () => { + it("keeps explicit empty priority routes empty unless the preset includes that priority", () => { + const rawConfig: Record = { + defaults: { notifiers: ["slack"] }, + notificationRouting: { + urgent: [], + action: ["slack"], + warning: [], + }, + }; + + applyNotifierRoutingPreset(rawConfig, "desktop", "urgent-action"); + + expect(rawConfig["notificationRouting"]).toEqual({ + urgent: ["desktop"], + action: ["slack", "desktop"], + warning: [], + info: ["slack"], + }); + }); + + it("uses defaults only when a priority route is missing", () => { + const rawConfig: Record = { + defaults: { notifiers: ["slack"] }, + notificationRouting: { + urgent: ["pager"], + }, + }; + + applyNotifierRoutingPreset(rawConfig, "desktop", "urgent-only"); + + expect(rawConfig["notificationRouting"]).toEqual({ + urgent: ["pager", "desktop"], + action: ["slack"], + warning: ["slack"], + info: ["slack"], + }); + }); +}); + +describe("setup dashboard command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes dashboard notifier config with the urgent-action routing default", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "dashboard", + "--non-interactive", + "--limit", + "75", + ]); + + const written = String(mockWriteFileSync.mock.calls[0][1]); + const parsed = parseYaml(written) as { + notifiers?: Record; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["dashboard"]).toEqual({ plugin: "dashboard", limit: 75 }); + expect(parsed.notificationRouting?.urgent).toContain("dashboard"); + expect(parsed.notificationRouting?.action).toContain("dashboard"); + expect(parsed.notificationRouting?.warning ?? []).not.toContain("dashboard"); + expect(parsed.notificationRouting?.info ?? []).not.toContain("dashboard"); + }); + + it("prints status without mutating config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "dashboard", "--status"]); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup composio command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockComposioConstructorOptions.length = 0; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockAuthConfigsList.mockResolvedValue({ + items: [{ id: "auth_slack_123", toolkit: { slug: "slack" } }], + }); + mockAuthConfigsCreate.mockResolvedValue({ + id: "auth_slack_created", + toolkit: { slug: "slack" }, + }); + mockAuthConfigsRetrieve.mockResolvedValue({ + id: "auth_slack_123", + toolkit: { slug: "slack" }, + toolAccessConfig: {}, + }); + mockConnectedAccountsList.mockResolvedValue({ + items: [ + { + id: "ca_slack_123", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockImplementation((id: string) => { + const toolkit = id.includes("discord") + ? "discordbot" + : id.includes("gmail") + ? "gmail" + : "slack"; + return Promise.resolve({ + id, + status: "ACTIVE", + toolkit: { slug: toolkit }, + isDisabled: false, + }); + }); + mockConnectedAccountsWaitForConnection.mockResolvedValue({ + id: "ca_waited", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }); + mockConnectedAccountsLink.mockResolvedValue({ + id: "conn_req_123", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_authorized", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }), + }); + mockConnectedAccountsInitiate.mockResolvedValue({ + id: "ca_discord_123", + status: "ACTIVE", + }); + mockToolkitsAuthorize.mockResolvedValue({ + id: "conn_req_123", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_authorized", + status: "ACTIVE", + toolkit: { slug: "slack" }, + isDisabled: false, + }), + }); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: vi.fn().mockResolvedValue({ id: "1234567890", name: "general" }), + }); + vi.stubGlobal("fetch", mockFetch); + for (const fn of Object.values(mockClack)) fn.mockReset(); + mockClack.confirm.mockResolvedValue(true); + mockClack.isCancel.mockReturnValue(false); + mockClack.password.mockResolvedValue("ak_interactive"); + mockClack.select.mockResolvedValue("slack"); + mockClack.text.mockResolvedValue(""); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the composio setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "composio")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-slack")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-discord")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-discord-bot")).toBe(true); + expect(setup?.commands.some((command) => command.name() === "composio-mail")).toBe(true); + }); + + it("runs the interactive Composio hub and writes Slack config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("slack") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_slack_123") + .mockResolvedValueOnce("change") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("iamasx"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockClack.intro).toHaveBeenCalledWith("AO Composio notifier setup"); + expect(mockClack.select).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: "Which Composio app do you want to configure?", + options: expect.arrayContaining([ + expect.objectContaining({ value: "slack" }), + expect.objectContaining({ value: "discord-webhook" }), + expect.objectContaining({ value: "discord-bot" }), + expect.objectContaining({ value: "gmail" }), + ]), + }), + ); + expect(mockComposioConstructorOptions).toEqual([{ apiKey: "ak_interactive" }]); + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["aoagent"], + toolkitSlugs: ["slack"], + statuses: ["ACTIVE"], + limit: 25, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "slack", + composioApiKey: "ak_interactive", + userId: "aoagent", + channelName: "iamasx", + connectedAccountId: "ca_slack_123", + }); + expect(parsed.defaults?.notifiers).toContain("composio"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio"); + }); + + it("interactive Slack setup can generate a Composio connect link", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockClack.select + .mockResolvedValueOnce("slack") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("create-link") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockAuthConfigsList).toHaveBeenCalledWith({ toolkit: "slack" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_slack_123", { + allowMultiple: true, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_authorized"); + }); + + it("interactive Slack setup shows navigation after an unfinished connect link", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_123", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + mockClack.select + .mockResolvedValueOnce("slack") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("create-link") + .mockResolvedValueOnce("check-active") + .mockResolvedValueOnce("ca_slack_123") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After opening the Composio Slack connect link, what do you want to do?", + options: expect.arrayContaining([ + expect.objectContaining({ value: "check-active" }), + expect.objectContaining({ value: "retry-link" }), + expect.objectContaining({ value: "enter-id" }), + expect.objectContaining({ value: "back" }), + expect.objectContaining({ value: "cancel" }), + ]), + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_slack_123"); + }); + + it("runs the interactive Composio hub and writes Discord webhook config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce( + "https://discord.com/api/webhooks/1234567890/webhook-token", + ); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Webhook Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "webhook-token" }, + }); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + expect(mockConnectedAccountsInitiate).toHaveBeenCalled(); + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to configure the Composio Discord webhook connected account?", + }), + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + userId: "aoagent", + toolVersion: "20260429_01", + composioApiKey: "ak_interactive", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.notifiers?.["composio-discord"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio"); + }); + + it("interactive Discord webhook setup can reuse existing config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: webhook + composioApiKey: ak_existing + userId: ao-existing + webhookUrl: https://discord.com/api/webhooks/old/webhook-token + connectedAccountId: ca_discord_existing +projects: + my-app: + name: my-app +`); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_discord_existing"); + expect(mockConnectedAccountsInitiate).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + webhookUrl: "https://discord.com/api/webhooks/old/webhook-token", + userId: "ao-existing", + connectedAccountId: "ca_discord_existing", + }); + }); + + it("interactive Discord webhook setup can show creation steps before URL entry", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("show-steps") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("https://discord.com/api/webhooks/222/webhook-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After creating the Discord webhook, what do you want to do?", + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("webhookUrl: https://discord.com/api/webhooks/222/webhook-token"); + expect(writtenYaml).toContain("connectedAccountId: ca_discord_123"); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + }); + + it("interactive Discord webhook setup replaces the canonical Composio notifier and clears stale app fields", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: slack + composioApiKey: ak_existing + userId: ao-existing + channelName: agents + connectedAccountId: ca_slack_old + emailTo: old@example.com +projects: + my-app: + name: my-app +`); + mockClack.select + .mockResolvedValueOnce("discord-webhook") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("https://discord.com/api/webhooks/333/webhook-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + defaults?: { notifiers?: string[] }; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/333/webhook-token", + userId: "ao-existing", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.notifiers?.["composio"]?.channelName).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.emailTo).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("interactive Discord bot setup creates a connected account and writes the canonical Composio notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + process.env.DISCORD_BOT_TOKEN = ""; + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: webhook + composioApiKey: ak_existing + userId: ao-existing + webhookUrl: https://discord.com/api/webhooks/old/webhook-token + channelName: stale-channel + emailTo: old@example.com +projects: + my-app: + name: my-app +`); + mockAuthConfigsCreate.mockResolvedValueOnce({ + id: "auth_discord_created", + toolkit: { slug: "discordbot" }, + }); + mockClack.select + .mockResolvedValueOnce("discord-bot") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-id") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("1234567890"); + mockClack.password.mockResolvedValueOnce("bot-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockFetch).toHaveBeenCalledWith("https://discord.com/api/v10/channels/1234567890", { + headers: { + Authorization: "Bot bot-token", + }, + }); + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Bot Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "bot-token" }, + }); + expect(mockConnectedAccountsInitiate).toHaveBeenCalledWith( + "ao-existing", + "auth_discord_created", + { + allowMultiple: true, + config: { + authScheme: "BEARER_TOKEN", + val: { + status: "ACTIVE", + token: "bot-token", + }, + }, + }, + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + defaults?: { notifiers?: string[] }; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + userId: "ao-existing", + connectedAccountId: "ca_discord_123", + toolVersion: "20260429_01", + }); + expect(parsed.notifiers?.["composio"]?.webhookUrl).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.channelName).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.emailTo).toBeUndefined(); + expect(writtenYaml).not.toContain("bot-token"); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("interactive Discord bot setup reuses an existing connected account", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: bot + composioApiKey: ak_existing + userId: ao-existing + channelId: "1234567890" + connectedAccountId: ca_discord_existing +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValueOnce({ + id: "ca_discord_existing", + status: "ACTIVE", + toolkit: { slug: "discordbot" }, + isDisabled: false, + }); + mockClack.select + .mockResolvedValueOnce("discord-bot") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("write"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_discord_existing"); + expect(mockAuthConfigsCreate).not.toHaveBeenCalled(); + expect(mockConnectedAccountsInitiate).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + connectedAccountId: "ca_discord_existing", + userId: "ao-existing", + }); + }); + + it("interactive Gmail setup chooses an active account and writes the canonical Composio notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: discord + mode: webhook + composioApiKey: ak_existing + userId: ao-existing + webhookUrl: https://discord.com/api/webhooks/old/webhook-token + channelName: stale-channel +projects: + my-app: + name: my-app +`); + mockConnectedAccountsList.mockResolvedValueOnce({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_gmail_123") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["ao-existing"], + toolkitSlugs: ["gmail"], + statuses: ["ACTIVE"], + limit: 25, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + userId: "ao-existing", + connectedAccountId: "ca_gmail_123", + toolVersion: "20260506_01", + }); + expect(parsed.notifiers?.["composio"]?.mode).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.webhookUrl).toBeUndefined(); + expect(parsed.notifiers?.["composio"]?.channelName).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("interactive Gmail setup reuses an existing connected account", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: gmail + composioApiKey: ak_existing + userId: ao-existing + emailTo: admin@example.com + connectedAccountId: ca_gmail_existing +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_existing", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("write"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_gmail_existing"); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + defaultApp: "gmail", + emailTo: "admin@example.com", + connectedAccountId: "ca_gmail_existing", + userId: "ao-existing", + }); + }); + + it("interactive Gmail setup can generate a connect link from an existing auth config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockAuthConfigsList.mockResolvedValueOnce({ + items: [ + { + id: "auth_gmail_send", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }, + ], + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_gmail", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }), + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("create-link") + .mockResolvedValueOnce("choose-existing") + .mockResolvedValueOnce("auth_gmail_send") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio"]); + + expect(mockAuthConfigsCreate).not.toHaveBeenCalledWith("gmail", expect.anything()); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_gmail_send", { + allowMultiple: true, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_gmail_authorized"); + expect(writtenYaml).toContain("emailTo: admin@example.com"); + }); + + it("interactive Gmail setup rejects accounts without Gmail send access", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: composio + defaultApp: gmail + composioApiKey: ak_existing + userId: ao-existing + emailTo: admin@example.com + connectedAccountId: ca_gmail_bad +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_bad", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: "openid https://www.googleapis.com/auth/userinfo.email", + }, + }); + mockAuthConfigsRetrieve.mockResolvedValueOnce(null); + mockClack.select + .mockResolvedValueOnce("gmail") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("use-existing") + .mockResolvedValueOnce("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "composio"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("interactive hub can be cancelled from app choices", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "composio"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("interactive Composio Slack setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_slack_123") + .mockResolvedValueOnce("change") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("iamasx"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-slack"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-slack"]).toMatchObject({ + plugin: "composio", + defaultApp: "slack", + composioApiKey: "ak_interactive", + userId: "aoagent", + channelName: "iamasx", + connectedAccountId: "ca_slack_123", + }); + expect(parsed.notifiers?.["composio"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio-slack"); + }); + + it("preserves an existing custom Composio userId", async () => { + mockReadFileSync.mockReturnValue(` +notifiers: + composio-slack: + plugin: composio + defaultApp: slack + userId: existing-user +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-slack", + "--api-key", + "ak_test", + "--connected-account-id", + "ca_slack_123", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-slack"]?.["userId"]).toBe("existing-user"); + }); + + it("interactive Composio Discord webhook setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-url") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("urgent-action") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce( + "https://discord.com/api/webhooks/1234567890/webhook-token", + ); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-discord"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-discord"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + userId: "aoagent", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.notifiers?.["composio"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio-discord"); + }); + + it("interactive Composio Discord bot setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + process.env.DISCORD_BOT_TOKEN = ""; + mockAuthConfigsCreate.mockResolvedValueOnce({ + id: "auth_discord_created", + toolkit: { slug: "discordbot" }, + }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-id") + .mockResolvedValueOnce("create-account") + .mockResolvedValueOnce("write"); + mockClack.text.mockResolvedValueOnce("1234567890"); + mockClack.password.mockResolvedValueOnce("ak_interactive").mockResolvedValueOnce("bot-token"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-discord-bot"]); + + expect(mockFetch).toHaveBeenCalledWith("https://discord.com/api/v10/channels/1234567890", { + headers: { + Authorization: "Bot bot-token", + }, + }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-discord-bot"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + userId: "aoagent", + connectedAccountId: "ca_discord_123", + }); + expect(writtenYaml).not.toContain("bot-token"); + expect(parsed.defaults?.notifiers).toContain("composio-discord-bot"); + }); + + it("interactive Composio mail setup writes the dedicated notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsList.mockResolvedValueOnce({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_gmail_123") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio-mail"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-mail"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + userId: "aoagent", + connectedAccountId: "ca_gmail_123", + }); + expect(parsed.notifiers?.["composio"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio-mail"); + }); + + it("interactive Composio direct app flag writes the canonical notifier", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockConnectedAccountsList.mockResolvedValueOnce({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + mockClack.select + .mockResolvedValueOnce("enter-new") + .mockResolvedValueOnce("use-current") + .mockResolvedValueOnce("enter-email") + .mockResolvedValueOnce("choose-active") + .mockResolvedValueOnce("ca_gmail_123") + .mockResolvedValueOnce("write"); + mockClack.password.mockResolvedValueOnce("ak_interactive"); + mockClack.text.mockResolvedValueOnce("admin@example.com"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "composio", "--gmail"]); + + expect(mockClack.select).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: "Which Composio app do you want to configure?", + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + connectedAccountId: "ca_gmail_123", + }); + expect(parsed.notifiers?.["composio-mail"]).toBeUndefined(); + expect(parsed.defaults?.notifiers).toContain("composio"); + }); + + it("writes Composio config with a discovered Slack connected account", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--channel", + "iamasx", + "--non-interactive", + ]); + + expect(mockComposioConstructorOptions).toEqual([{ apiKey: "ak_test" }]); + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["ao-user"], + toolkitSlugs: ["slack"], + statuses: ["ACTIVE"], + limit: 25, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio"]).toMatchObject({ + plugin: "composio", + defaultApp: "slack", + composioApiKey: "ak_test", + userId: "ao-user", + channelName: "iamasx", + connectedAccountId: "ca_slack_123", + }); + expect(parsed.defaults?.notifiers).toContain("composio"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio"); + expect(parsed.notificationRouting?.["action"]).toContain("composio"); + expect(parsed.notificationRouting?.["warning"]).toContain("composio"); + expect(parsed.notificationRouting?.["info"]).toContain("composio"); + }); + + it("uses COMPOSIO_API_KEY and does not write the env value to config", async () => { + process.env.COMPOSIO_API_KEY = "ak_env"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--user-id", + "ao-user", + "--non-interactive", + ]); + + expect(mockComposioConstructorOptions).toEqual([{ apiKey: "ak_env" }]); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).not.toContain("ak_env"); + }); + + it("verifies and stores an explicit connected account id", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--connected-account-id", + "ca_explicit", + "--non-interactive", + ]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_explicit"); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio"]?.["connectedAccountId"]).toBe("ca_explicit"); + }); + + it("fails in non-interactive mode when multiple Slack accounts need selection", async () => { + mockConnectedAccountsList.mockResolvedValue({ + items: [ + { id: "ca_one", status: "ACTIVE", toolkit: { slug: "slack" } }, + { id: "ca_two", status: "ACTIVE", toolkit: { slug: "slack" } }, + ], + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("creates a Slack connect request when no active account exists", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsList).toHaveBeenCalledWith({ toolkit: "slack" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("ao-user", "auth_slack_123", { + allowMultiple: true, + }); + expect(mockToolkitsAuthorize).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_authorized"); + }); + + it("does not write Slack Composio config when a connect request does not complete", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_slack", + redirectUrl: "https://composio.dev/connect/slack", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("ao-user", "auth_slack_123", { + allowMultiple: true, + }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("creates a Slack auth config before linking when none exists", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsList.mockResolvedValue({ items: [] }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("slack", { + type: "use_composio_managed_auth", + name: "Slack Auth Config", + }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("ao-user", "auth_slack_created", { + allowMultiple: true, + }); + }); + + it("shows status without writing config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--status", + ]); + + expect(mockConnectedAccountsList).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting composio notifier config unless --force is set", async () => { + mockReadFileSync.mockReturnValue(` +notifiers: + composio: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio", + "--api-key", + "ak_test", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes Composio Discord webhook config with a connected account", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-discord", + "--api-key", + "ak_test", + "--webhook-url", + "https://discord.com/api/webhooks/1234567890/webhook-token", + "--non-interactive", + ]); + + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Webhook Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "webhook-token" }, + }); + expect(mockConnectedAccountsInitiate).toHaveBeenCalled(); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio-discord"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + userId: "aoagent", + toolVersion: "20260429_01", + composioApiKey: "ak_test", + connectedAccountId: "ca_discord_123", + }); + expect(parsed.defaults?.notifiers).toContain("composio-discord"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio-discord"); + expect(writtenYaml).not.toContain("botToken"); + }); + + it("writes Composio Discord bot config and does not persist the bot token", async () => { + mockAuthConfigsCreate.mockResolvedValueOnce({ + id: "auth_discord_created", + toolkit: { slug: "discordbot" }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-discord-bot", + "--api-key", + "ak_test", + "--channel-id", + "1234567890", + "--bot-token", + "bot-token", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith("https://discord.com/api/v10/channels/1234567890", { + headers: { + Authorization: "Bot bot-token", + }, + }); + expect(mockAuthConfigsCreate).toHaveBeenCalledWith("discordbot", { + type: "use_custom_auth", + name: "Discord Bot Auth Config", + authScheme: "BEARER_TOKEN", + credentials: { token: "bot-token" }, + }); + expect(mockConnectedAccountsInitiate).toHaveBeenCalledWith("aoagent", "auth_discord_created", { + allowMultiple: true, + config: { + authScheme: "BEARER_TOKEN", + val: { + status: "ACTIVE", + token: "bot-token", + }, + }, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio-discord-bot"]).toMatchObject({ + plugin: "composio", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + userId: "aoagent", + connectedAccountId: "ca_discord_123", + toolVersion: "20260429_01", + composioApiKey: "ak_test", + }); + expect(parsed.defaults?.notifiers).toContain("composio-discord-bot"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio-discord-bot"); + expect(writtenYaml).not.toContain("bot-token"); + }); + + it("fails Discord bot setup when the bot cannot access the channel", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + json: vi.fn().mockResolvedValue({ message: "Missing Access" }), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio-discord-bot", + "--api-key", + "ak_test", + "--channel-id", + "1234567890", + "--bot-token", + "bot-token", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes Discord bot config from an explicit connected account without a bot token", async () => { + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_discord_explicit", + status: "ACTIVE", + toolkit: { slug: "discordbot" }, + isDisabled: false, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-discord-bot", + "--api-key", + "ak_test", + "--channel-id", + "1234567890", + "--connected-account-id", + "ca_discord_explicit", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockConnectedAccountsInitiate).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-discord-bot"]?.["connectedAccountId"]).toBe( + "ca_discord_explicit", + ); + }); + + it("writes Composio mail config with a discovered Gmail connected account", async () => { + mockConnectedAccountsList.mockResolvedValue({ + items: [ + { + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }, + ], + }); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_123", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--user-id", + "ao-user", + "--email-to", + "admin@example.com", + "--non-interactive", + ]); + + expect(mockConnectedAccountsList).toHaveBeenCalledWith({ + userIds: ["ao-user"], + toolkitSlugs: ["gmail"], + statuses: ["ACTIVE"], + limit: 25, + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record>; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["composio-mail"]).toMatchObject({ + plugin: "composio", + defaultApp: "gmail", + emailTo: "admin@example.com", + userId: "ao-user", + connectedAccountId: "ca_gmail_123", + toolVersion: "20260506_01", + composioApiKey: "ak_test", + }); + expect(parsed.defaults?.notifiers).toContain("composio-mail"); + expect(parsed.notificationRouting?.["urgent"]).toContain("composio-mail"); + }); + + it("fails when no usable Gmail connected account exists", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockAuthConfigsCreate).not.toHaveBeenCalledWith("gmail", expect.anything()); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("prints a Gmail connect URL without writing config when --connect does not complete", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsList.mockResolvedValueOnce({ + items: [ + { + id: "auth_gmail_send", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }, + ], + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_123", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connect", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsList).toHaveBeenCalledWith({ toolkit: "gmail" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_gmail_send", { + allowMultiple: true, + }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes mail config when --connect completes with a Gmail connected account", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsList.mockResolvedValueOnce({ + items: [ + { + id: "auth_gmail_send", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }, + ], + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_123", + waitForConnection: vi.fn().mockResolvedValue({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + }), + }); + mockConnectedAccountsGet.mockResolvedValueOnce({ + id: "ca_gmail_authorized", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connect", + "--wait-ms", + "1", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("connectedAccountId: ca_gmail_authorized"); + }); + + it("uses an explicit Gmail auth config id for --connect", async () => { + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + mockAuthConfigsRetrieve.mockResolvedValueOnce({ + id: "auth_gmail_custom", + toolkit: { slug: "gmail" }, + toolAccessConfig: { + toolsForConnectedAccountCreation: ["GMAIL_SEND_EMAIL"], + }, + }); + mockConnectedAccountsLink.mockResolvedValueOnce({ + id: "conn_req_gmail", + redirectUrl: "https://connect.composio.dev/link/lk_custom", + waitForConnection: vi.fn().mockResolvedValue(null), + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connect", + "--auth-config-id", + "auth_gmail_custom", + "--wait-ms", + "1", + "--non-interactive", + ]); + + expect(mockAuthConfigsList).not.toHaveBeenCalledWith({ toolkit: "gmail" }); + expect(mockConnectedAccountsLink).toHaveBeenCalledWith("aoagent", "auth_gmail_custom", { + allowMultiple: true, + }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes mail config from an explicit Gmail connected account", async () => { + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_explicit", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: + "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.metadata", + }, + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "composio-mail", + "--api-key", + "ak_test", + "--email-to", + "admin@example.com", + "--connected-account-id", + "ca_gmail_explicit", + "--non-interactive", + ]); + + expect(mockConnectedAccountsGet).toHaveBeenCalledWith("ca_gmail_explicit"); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record>; + }; + expect(parsed.notifiers?.["composio-mail"]?.["connectedAccountId"]).toBe("ca_gmail_explicit"); + }); + + it("fails when the existing Gmail account lacks send access and no replacement exists", async () => { + mockReadFileSync.mockReturnValue(` +notifiers: + composio-mail: + plugin: composio + defaultApp: gmail + composioApiKey: ak_existing + emailTo: admin@example.com + connectedAccountId: ca_gmail_old +projects: + my-app: + name: my-app +`); + mockConnectedAccountsGet.mockResolvedValue({ + id: "ca_gmail_old", + status: "ACTIVE", + toolkit: { slug: "gmail" }, + isDisabled: false, + data: { + scope: "openid https://www.googleapis.com/auth/userinfo.email", + }, + }); + mockConnectedAccountsList.mockResolvedValue({ items: [] }); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect( + program.parseAsync(["node", "test", "setup", "composio-mail", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockAuthConfigsCreate).not.toHaveBeenCalledWith("gmail", expect.anything()); + expect(mockConnectedAccountsLink).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + describe("setup openclaw command", () => { const originalEnv = { ...process.env }; @@ -137,12 +2071,15 @@ describe("setup openclaw command", () => { "--non-interactive", ]); - // Code writes YAML config + shell profile export — at least one write expect(mockWriteFileSync).toHaveBeenCalled(); const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; expect(writtenYaml).toContain("openclaw"); expect(writtenYaml).toContain("plugin: openclaw"); expect(writtenYaml).toContain("http://127.0.0.1:18789/hooks/agent"); + expect(writtenYaml).toContain("token: test-token"); + expect(mockWriteFileSync.mock.calls.some(([path]) => String(path).includes(".zshrc"))).toBe( + false, + ); }); it("reads token from OPENCLAW_HOOKS_TOKEN env var and skips validation", async () => { @@ -162,6 +2099,36 @@ describe("setup openclaw command", () => { // Non-interactive mode skips pre-write validation expect(mockValidateToken).not.toHaveBeenCalled(); expect(mockWriteFileSync).toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("${OPENCLAW_HOOKS_TOKEN}"); + }); + + it("reads token from OpenClaw config without copying it into AO config", async () => { + const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); + mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath); + mockReadFileSync.mockImplementation((path: string) => { + if (path === "/tmp/agent-orchestrator.yaml") return MINIMAL_CONFIG; + if (path === openclawConfigPath) { + return JSON.stringify({ hooks: { token: "openclaw-owned-token" } }); + } + return ""; + }); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("openclawConfigPath: ~/.openclaw/openclaw.json"); + expect(writtenYaml).not.toContain("openclaw-owned-token"); + expect(mockWriteFileSync.mock.calls).toHaveLength(1); }); it("reads URL from OPENCLAW_GATEWAY_URL env var and skips validation", async () => { @@ -202,6 +2169,25 @@ describe("setup openclaw command", () => { expect(writtenYaml).not.toContain("/hooks/agent/hooks/agent"); }); + it("refreshes existing config without requiring --url", async () => { + process.env["OPENCLAW_HOOKS_TOKEN"] = "env-token"; + mockReadFileSync.mockReturnValue(CONFIG_WITH_OPENCLAW); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--refresh", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("url: http://127.0.0.1:18789/hooks/agent"); + expect(mockDetectOpenClawInstallation).not.toHaveBeenCalled(); + }); + it("skips token validation and writes config in non-interactive mode", async () => { const program = createProgram(); @@ -224,6 +2210,32 @@ describe("setup openclaw command", () => { }); }); + describe("status", () => { + it("shows status without writing config", async () => { + process.env["OPENCLAW_HOOKS_TOKEN"] = "env-token"; + mockReadFileSync.mockReturnValue(CONFIG_WITH_OPENCLAW); + mockDetectOpenClawInstallation.mockResolvedValue({ + state: "running", + gatewayUrl: "http://127.0.0.1:18789", + binaryPath: "/usr/local/bin/openclaw", + configPath: join(homedir(), ".openclaw", "openclaw.json"), + probe: { reachable: true, httpStatus: 200 }, + }); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "openclaw", "--status"]); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockDetectOpenClawInstallation).toHaveBeenCalledWith( + "http://127.0.0.1:18789/hooks/agent", + ); + expect(mockValidateToken).toHaveBeenCalledWith( + "http://127.0.0.1:18789/hooks/agent", + "env-token", + ); + }); + }); + describe("config writing", () => { it("adds openclaw to defaults.notifiers", async () => { const program = createProgram(); @@ -316,6 +2328,56 @@ projects: expect(parsed.defaults?.notifiers?.filter((name) => name === "openclaw")).toHaveLength(1); }); + it("preserves defaults and routing when OpenClaw refresh has no routing preset", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +defaults: + notifiers: + - slack +notifiers: + slack: + plugin: slack + openclaw: + plugin: openclaw + url: http://127.0.0.1:18789/hooks/agent + token: tok +notificationRouting: + urgent: [] + action: + - slack + warning: [] + info: + - slack +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--refresh", + "--no-test", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + defaults?: { notifiers?: string[] }; + notificationRouting?: Record; + }; + expect(parsed.defaults?.notifiers).toEqual(["slack"]); + expect(parsed.notificationRouting).toEqual({ + urgent: [], + action: ["slack"], + warning: [], + info: ["slack"], + }); + }); + it("writes correct notifier block structure", async () => { const program = createProgram(); @@ -334,7 +2396,7 @@ projects: const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; expect(writtenYaml).toContain("plugin: openclaw"); expect(writtenYaml).toContain("http://custom:9999/hooks/agent"); - expect(writtenYaml).toContain("${OPENCLAW_HOOKS_TOKEN}"); + expect(writtenYaml).toContain("token: tok"); expect(writtenYaml).toContain("retries: 3"); expect(writtenYaml).toContain("retryDelayMs: 1000"); expect(writtenYaml).toContain("wakeMode: now"); @@ -394,7 +2456,7 @@ projects: expect(parsed.notificationRouting?.["info"]).toContain("openclaw"); }); - it("merges existing allowedSessionKeyPrefixes in openclaw.json", async () => { + it("does not rewrite OpenClaw config when using a token from OpenClaw config", async () => { const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); mockExistsSync.mockImplementation((path: string) => path === openclawConfigPath); @@ -425,31 +2487,16 @@ projects: "openclaw", "--url", "http://127.0.0.1:18789/hooks/agent", - "--token", - "new-token", "--non-interactive", ]); const openclawWrite = mockWriteFileSync.mock.calls.find( ([path]) => path === openclawConfigPath, ); - expect(openclawWrite).toBeDefined(); - - const writtenJson = JSON.parse(openclawWrite![1] as string) as { - hooks: { - token: string; - enabled: boolean; - allowRequestSessionKey: boolean; - allowedSessionKeyPrefixes: string[]; - }; - otherConfig: boolean; - }; - - expect(writtenJson.otherConfig).toBe(true); - expect(writtenJson.hooks.token).toBe("new-token"); - expect(writtenJson.hooks.enabled).toBe(true); - expect(writtenJson.hooks.allowRequestSessionKey).toBe(true); - expect(writtenJson.hooks.allowedSessionKeyPrefixes).toEqual(["legacy:", "hook:"]); + expect(openclawWrite).toBeUndefined(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).toContain("openclawConfigPath: ~/.openclaw/openclaw.json"); + expect(writtenYaml).not.toContain("old-token"); }); it("preserves existing projects in config", async () => { @@ -490,44 +2537,6 @@ projects: expect(mockWriteFileSync.mock.calls[0][0]).toBe("/custom/path/agent-orchestrator.yaml"); }); - - it("emits setup_degraded instead of setup_failed when OpenClaw JSON write falls back to manual instructions", async () => { - const openclawConfigPath = join(homedir(), ".openclaw", "openclaw.json"); - mockWriteFileSync.mockImplementation((path: string) => { - if (path === openclawConfigPath) { - throw new Error("permission denied"); - } - }); - const program = createProgram(); - - await program.parseAsync([ - "node", - "test", - "setup", - "openclaw", - "--url", - "http://127.0.0.1:18789/hooks/agent", - "--token", - "tok", - "--non-interactive", - ]); - - const events = recordedEvents(); - expect(events).toContainEqual( - expect.objectContaining({ - kind: "cli.setup_degraded", - source: "cli", - level: "warn", - data: expect.objectContaining({ reason: "openclaw_json_write_failed" }), - }), - ); - expect(events).not.toContainEqual( - expect.objectContaining({ - kind: "cli.setup_failed", - data: expect.objectContaining({ reason: "openclaw_json_write_failed" }), - }), - ); - }); }); describe("error handling", () => { @@ -604,22 +2613,1756 @@ projects: expect(exitSpy).toHaveBeenCalledWith(1); }); - it("auto-generates token when --token missing in non-interactive mode", async () => { + it("fails when no OpenClaw-owned token is available in non-interactive mode", async () => { delete process.env["OPENCLAW_HOOKS_TOKEN"]; const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); - await program.parseAsync([ - "node", - "test", - "setup", - "openclaw", - "--url", - "http://127.0.0.1:18789/hooks/agent", - "--non-interactive", - ]); + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); - // nonInteractiveSetup auto-generates a token when none is provided - expect(mockWriteFileSync).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting openclaw notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +defaults: {} +notifiers: + openclaw: + plugin: webhook + url: https://example.com/hook +projects: {} +`); + const program = createProgram(); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); }); }); }); + +describe("setup desktop command", () => { + const originalEnv = { ...process.env }; + const sourceApp = "/tmp/source/AO Notifier.app"; + const targetApp = "/tmp/home/Applications/AO Notifier.app"; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + process.env["AO_DESKTOP_SETUP_PLATFORM"] = "darwin"; + process.env["AO_NOTIFIER_MACOS_APP_PATH"] = sourceApp; + process.env["AO_DESKTOP_APP_INSTALL_PATH"] = targetApp; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockMkdirSync.mockImplementation(() => undefined); + mockCpSync.mockImplementation(() => undefined); + mockRmSync.mockImplementation(() => undefined); + mockExistsSync.mockImplementation((path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier"), + ); + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes("--permission-status-json")) { + return '{"status":"authorized","bundleId":"com.aoagents.notifier"}'; + } + if (args.includes("--version-json")) { + return '{"name":"AO Notifier","version":"0.6.0","bundleId":"com.aoagents.notifier"}'; + } + if (args.includes("--request-permission")) { + return '{"status":"authorized","bundleId":"com.aoagents.notifier"}'; + } + return ""; + }); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("registers the desktop setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "desktop")).toBe(true); + }); + + it("installs the bundled app and wires desktop routing to all priorities", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]); + + expect(mockCpSync).toHaveBeenCalledWith(sourceApp, targetApp, { recursive: true }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "ao-app", + dashboardUrl: "http://localhost:3000", + }); + expect(parsed.notificationRouting?.["urgent"]).toContain("desktop"); + expect(parsed.notificationRouting?.["action"]).toContain("desktop"); + expect(parsed.notificationRouting?.["warning"]).toContain("desktop"); + expect(parsed.notificationRouting?.["info"]).toContain("desktop"); + }); + + it("configures terminal-notifier backend without installing AO Notifier.app", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "terminal-notifier", + "--non-interactive", + ]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith("terminal-notifier", ["--version"], { + stdio: "ignore", + windowsHide: true, + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "terminal-notifier", + [ + "-title", + "AO Notifier", + "-message", + "Desktop notifications are ready.", + "-open", + "http://localhost:3000", + ], + expect.any(Object), + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "terminal-notifier", + dashboardUrl: "http://localhost:3000", + }); + }); + + it("configures osascript backend without installing AO Notifier.app", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "osascript", + "--non-interactive", + ]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith("osascript", ["--version"], { + stdio: "ignore", + windowsHide: true, + }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "osascript", + ["-e", 'display notification "Desktop notifications are ready." with title "AO Notifier"'], + expect.any(Object), + ); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "osascript", + }); + }); + + it("refreshes existing backend and dashboard URL without reinstalling when app exists", async () => { + mockReadFileSync.mockReturnValue(` +port: 4217 +notifiers: + desktop: + plugin: desktop + backend: terminal-notifier + dashboardUrl: http://localhost:3000 + sound: false +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--refresh", + "--no-test", + "--non-interactive", + ]); + + expect(mockCpSync).not.toHaveBeenCalled(); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { plugin?: string; backend?: string; dashboardUrl?: string; sound?: boolean } + >; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "terminal-notifier", + dashboardUrl: "http://localhost:4217", + sound: false, + }); + }); + + it("uses explicit dashboard URL override", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "osascript", + "--dashboard-url", + "http://localhost:7777", + "--no-test", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]?.dashboardUrl).toBe("http://localhost:7777"); + }); + + it("installs and writes an explicit AO app path", async () => { + const customAppPath = "/tmp/custom/AO Notifier.app"; + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--app-path", + customAppPath, + "--force", + "--non-interactive", + ]); + + expect(mockCpSync).toHaveBeenCalledWith(sourceApp, customAppPath, { recursive: true }); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]?.appPath).toBe(customAppPath); + }); + + it("skips setup test notification with --no-test", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--no-test", + "--non-interactive", + ]); + + expect(mockExecFileSync).not.toHaveBeenCalledWith( + expect.stringContaining("ao-notifier"), + ["--notify-base64", expect.any(String)], + expect.any(Object), + ); + }); + + it("fails for missing terminal-notifier in non-interactive mode", async () => { + mockExecFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === "terminal-notifier" && args[0] === "--version") { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return ""; + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "desktop", + "--backend", + "terminal-notifier", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("preserves existing routing entries while adding desktop", async () => { + mockReadFileSync.mockReturnValue(` +port: 3001 +defaults: + notifiers: + - slack +notifiers: + slack: + plugin: slack +notificationRouting: + urgent: + - slack +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notificationRouting?: Record; + defaults?: { notifiers?: string[] }; + }; + expect(parsed.notificationRouting?.["urgent"]).toEqual(["slack", "desktop"]); + expect(parsed.notificationRouting?.["action"]).toEqual(["slack", "desktop"]); + expect(parsed.defaults?.notifiers).toEqual(["slack"]); + }); + + it("fails on conflicting desktop notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + desktop: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("stops immediately when interactive conflict replacement is declined", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + desktop: + plugin: webhook +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.confirm.mockResolvedValueOnce(false); + mockClack.isCancel.mockReturnValue(false); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop"]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("allows replacing conflicting desktop notifier config with --force", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + desktop: + plugin: webhook + url: http://example.com +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--force", "--non-interactive"]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["desktop"]).toMatchObject({ plugin: "desktop", backend: "ao-app" }); + }); + + it("reports denied notification permission without writing config", async () => { + mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes("--request-permission")) { + const error = new Error("Command failed") as Error & { stdout: Buffer; stderr: Buffer }; + error.stdout = Buffer.from('{"status":"denied","bundleId":"com.aoagents.notifier"}\n'); + error.stderr = Buffer.alloc(0); + throw error; + } + return ""; + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("System Settings")); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("refuses to install a non-macOS placeholder AO Notifier.app", async () => { + mockExistsSync.mockImplementation( + (path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier") || + path.endsWith("AO Notifier.app/Contents/Resources/ao-notifier-placeholder"), + ); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("non-macOS placeholder")); + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("shows status without installing or writing config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--status"]); + + expect(mockCpSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockExecFileSync).toHaveBeenCalledWith( + expect.stringContaining("ao-notifier"), + ["--version-json"], + expect.any(Object), + ); + }); + + it("uninstalls the app without changing config", async () => { + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "desktop", "--uninstall"]); + + expect(mockRmSync).toHaveBeenCalledWith(targetApp, { recursive: true, force: true }); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("exits on non-macOS install attempts", async () => { + process.env["AO_DESKTOP_SETUP_PLATFORM"] = "linux"; + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockCpSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup webhook command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + statusText: "No Content", + text: vi.fn().mockResolvedValue(""), + }); + vi.stubGlobal("fetch", mockFetch); + for (const fn of Object.values(mockClack)) fn.mockReset(); + mockClack.confirm.mockResolvedValue(true); + mockClack.isCancel.mockReturnValue(false); + mockClack.password.mockResolvedValue(""); + mockClack.select.mockResolvedValue("use-existing"); + mockClack.text.mockResolvedValue(""); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the webhook setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "webhook")).toBe(true); + }); + + it("interactive setup asks whether to use an existing webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("use-existing"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Webhook notifier is already configured. What do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith("https://old.example.com/ao-events", expect.any(Object)); + }); + + it("interactive setup can add a new webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("add-new").mockResolvedValueOnce("enter-url"); + mockClack.text.mockResolvedValueOnce(NEW_EXAMPLE_WEBHOOK_URL); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to configure the webhook URL?", + }), + ); + expect(mockFetch).toHaveBeenCalledWith("https://new.example.com/ao-events", expect.any(Object)); + }); + + it("interactive setup can navigate back from adding a new webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("add-new") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to configure the webhook URL?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith("https://old.example.com/ao-events", expect.any(Object)); + }); + + it("interactive setup can be cancelled before writing webhook config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "webhook"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("tests the endpoint and writes url-only webhook config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/ao-events", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { plugin?: string; url?: string; headers?: Record } + >; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["webhook"]).toMatchObject({ + plugin: "webhook", + url: "https://example.com/ao-events", + }); + expect(parsed.notifiers?.["webhook"]?.headers).toBeUndefined(); + expect(parsed.notificationRouting?.["urgent"]).toContain("webhook"); + expect(parsed.notificationRouting?.["action"]).toContain("webhook"); + expect(parsed.notificationRouting?.["warning"]).toContain("webhook"); + expect(parsed.notificationRouting?.["info"]).toContain("webhook"); + }); + + it("writes bearer auth into webhook headers when auth token is provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--auth-token", + "secret-token", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/ao-events", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + Authorization: "Bearer secret-token", + }, + }), + ); + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record }>; + }; + + expect(parsed.notifiers?.["webhook"]?.headers).toEqual({ + Authorization: "Bearer secret-token", + }); + }); + + it("does not write config when setup test fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue("bad token"), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--auth-token", + "bad-token", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes config without probing when --no-test is used", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--no-test", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("refreshes existing webhook config and preserves bearer token", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://old.example.com/ao-events + headers: + Authorization: Bearer existing-token + retries: 5 + retryDelayMs: 2500 +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--refresh", + "--url", + NEW_EXAMPLE_WEBHOOK_URL, + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { url?: string; headers?: Record; retries?: number; retryDelayMs?: number } + >; + }; + + expect(parsed.notifiers?.["webhook"]).toMatchObject({ + url: "https://new.example.com/ao-events", + headers: { Authorization: "Bearer existing-token" }, + retries: 5, + retryDelayMs: 2500, + }); + }); + + it("shows status without writing config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: webhook + url: https://example.com/ao-events +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "webhook", "--status"]); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting webhook notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + webhook: + plugin: slack +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "webhook", + "--url", + EXAMPLE_WEBHOOK_URL, + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup slack command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: vi.fn().mockResolvedValue("ok"), + }); + vi.stubGlobal("fetch", mockFetch); + for (const fn of Object.values(mockClack)) fn.mockReset(); + mockClack.confirm.mockResolvedValue(true); + mockClack.isCancel.mockReturnValue(false); + mockClack.select.mockResolvedValue("have-url"); + mockClack.text.mockResolvedValue(""); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the slack setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "slack")).toBe(true); + }); + + it("interactive setup asks whether to reuse an existing Slack webhook", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old + channel: "#old-agents" + username: Existing AO +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("use-existing"); + mockClack.text.mockResolvedValueOnce("#agents").mockResolvedValueOnce("AO"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Slack notifier is already configured. What do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/TOLD/BOLD/old", + expect.any(Object), + ); + }); + + it("interactive setup prints creation steps and waits for a Slack webhook URL", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("need-url").mockResolvedValueOnce("enter-url"); + mockClack.text + .mockResolvedValueOnce(SLACK_SECRET_WEBHOOK_URL) + .mockResolvedValueOnce("") + .mockResolvedValueOnce("AO"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Create a Slack incoming webhook")); + expect(mockClack.text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/T000/B000/secret", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from Slack webhook instructions", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("need-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text.mockResolvedValueOnce("").mockResolvedValueOnce("AO"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After creating the Slack webhook, what do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/TOLD/BOLD/old", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from changing the Slack webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("change-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text.mockResolvedValueOnce("").mockResolvedValueOnce("AO"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to change the Slack webhook URL?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Slack incoming webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/TOLD/BOLD/old", + expect.any(Object), + ); + }); + + it("interactive setup can be cancelled before writing Slack config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "slack"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("tests the endpoint and writes url-only Slack config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://hooks.slack.com/services/T000/B000/secret", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + channel?: string; + text?: string; + }; + expect(payload).toMatchObject({ + username: "Agent Orchestrator", + text: "AO Slack notifications are ready.", + }); + expect(payload.channel).toBeUndefined(); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { plugin?: string; webhookUrl?: string; username?: string; channel?: string } + >; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["slack"]).toMatchObject({ + plugin: "slack", + webhookUrl: "https://hooks.slack.com/services/T000/B000/secret", + username: "Agent Orchestrator", + }); + expect(parsed.notifiers?.["slack"]?.channel).toBeUndefined(); + expect(parsed.notificationRouting?.["urgent"]).toContain("slack"); + expect(parsed.notificationRouting?.["action"]).toContain("slack"); + expect(parsed.notificationRouting?.["warning"]).toContain("slack"); + expect(parsed.notificationRouting?.["info"]).toContain("slack"); + }); + + it("writes optional channel and username when provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--channel", + "#agents", + "--username", + "AO", + "--non-interactive", + ]); + + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + channel?: string; + }; + expect(payload).toMatchObject({ + username: "AO", + channel: "#agents", + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + expect(parsed.notifiers?.["slack"]).toMatchObject({ + username: "AO", + channel: "#agents", + }); + }); + + it("does not write config when Slack setup test fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: vi.fn().mockResolvedValue("no_service"), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_BAD_WEBHOOK_URL, + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes config without probing when --no-test is used", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--no-test", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("refreshes existing Slack config and preserves channel and username", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/TOLD/BOLD/old + channel: "#old-agents" + username: AO +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--refresh", + "--webhook-url", + SLACK_NEW_WEBHOOK_URL, + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record; + }; + + expect(parsed.notifiers?.["slack"]).toMatchObject({ + webhookUrl: "https://hooks.slack.com/services/TNEW/BNEW/new", + channel: "#old-agents", + username: "AO", + }); + }); + + it("shows status without writing config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: slack + webhookUrl: https://hooks.slack.com/services/T000/B000/secret +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "slack", "--status"]); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting Slack notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + slack: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "slack", + "--webhook-url", + SLACK_SECRET_WEBHOOK_URL, + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe("setup discord command", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + mockFindConfigFile.mockReturnValue("/tmp/agent-orchestrator.yaml"); + mockReadFileSync.mockReturnValue(MINIMAL_CONFIG); + mockWriteFileSync.mockImplementation(() => {}); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 204, + statusText: "No Content", + text: vi.fn().mockResolvedValue(""), + headers: { get: vi.fn().mockReturnValue(null) }, + }); + vi.stubGlobal("fetch", mockFetch); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + }); + + it("registers the discord setup command", () => { + const program = createProgram(); + const setup = program.commands.find((command) => command.name() === "setup"); + expect(setup?.commands.some((command) => command.name() === "discord")).toBe(true); + }); + + it("interactive setup asks whether to reuse an existing Discord webhook", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/existing/secret + username: Existing AO +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("use-existing"); + mockClack.text + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Discord notifier is already configured. What do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/existing/secret", + expect.any(Object), + ); + }); + + it("interactive setup prints creation steps and waits for a Discord webhook URL", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValueOnce("need-url").mockResolvedValueOnce("enter-url"); + mockClack.text + .mockResolvedValueOnce("https://discord.com/api/webhooks/123/secret") + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Create a Discord incoming webhook"), + ); + expect(mockClack.text).toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/123/secret", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from Discord webhook instructions", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/existing/secret +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("need-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "After creating the Discord webhook, what do you want to do?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/existing/secret", + expect.any(Object), + ); + }); + + it("interactive setup can navigate back from changing the Discord webhook URL", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/existing/secret +projects: + my-app: + name: my-app +`); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select + .mockResolvedValueOnce("change-url") + .mockResolvedValueOnce("back") + .mockResolvedValueOnce("use-existing"); + mockClack.text + .mockResolvedValueOnce("AO") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("") + .mockResolvedValueOnce("2") + .mockResolvedValueOnce("1000"); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord"]); + + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "How do you want to change the Discord webhook URL?", + }), + ); + expect(mockClack.text).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Discord webhook URL:" }), + ); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/existing/secret", + expect.any(Object), + ); + }); + + it("interactive setup can be cancelled before writing Discord config", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockClack.select.mockResolvedValue("cancel"); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = createProgram(); + + await expect(program.parseAsync(["node", "test", "setup", "discord"])).rejects.toThrow( + "process.exit", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("tests the endpoint and writes url-only Discord config", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/123/secret", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + content?: string; + avatar_url?: string; + }; + expect(payload).toMatchObject({ + username: "Agent Orchestrator", + content: "AO Discord notifications are ready.", + }); + expect(payload.avatar_url).toBeUndefined(); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { + plugin?: string; + webhookUrl?: string; + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: number; + retryDelayMs?: number; + } + >; + notificationRouting?: Record; + }; + + expect(parsed.notifiers?.["discord"]).toMatchObject({ + plugin: "discord", + webhookUrl: "https://discord.com/api/webhooks/123/secret", + username: "Agent Orchestrator", + retries: 2, + retryDelayMs: 1000, + }); + expect(parsed.notifiers?.["discord"]?.avatarUrl).toBeUndefined(); + expect(parsed.notifiers?.["discord"]?.threadId).toBeUndefined(); + expect(parsed.notificationRouting?.["urgent"]).toContain("discord"); + expect(parsed.notificationRouting?.["action"]).toContain("discord"); + expect(parsed.notificationRouting?.["warning"]).toContain("discord"); + expect(parsed.notificationRouting?.["info"]).toContain("discord"); + }); + + it("writes optional username avatar thread and retry config when provided", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--username", + "AO", + "--avatar-url", + "https://example.com/avatar.png", + "--thread-id", + "987654321", + "--retries", + "4", + "--retry-delay-ms", + "2500", + "--non-interactive", + ]); + + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/webhooks/123/secret?thread_id=987654321", + expect.any(Object), + ); + const payload = JSON.parse(mockFetch.mock.calls[0][1].body as string) as { + username?: string; + avatar_url?: string; + }; + expect(payload).toMatchObject({ + username: "AO", + avatar_url: "https://example.com/avatar.png", + }); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: number; + retryDelayMs?: number; + } + >; + }; + expect(parsed.notifiers?.["discord"]).toMatchObject({ + username: "AO", + avatarUrl: "https://example.com/avatar.png", + threadId: "987654321", + retries: 4, + retryDelayMs: 2500, + }); + }); + + it("does not write config when Discord setup test fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + text: vi.fn().mockResolvedValue("Unknown Webhook"), + }); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/bad", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("writes config without probing when --no-test is used", async () => { + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--no-test", + "--non-interactive", + ]); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it("refreshes existing Discord config and preserves optional values", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/old/old + username: AO + avatarUrl: https://example.com/avatar.png + threadId: "111" + retries: 5 + retryDelayMs: 3000 +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--refresh", + "--webhook-url", + "https://discord.com/api/webhooks/new/new", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { + notifiers?: Record< + string, + { + webhookUrl?: string; + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: number; + retryDelayMs?: number; + } + >; + }; + + expect(parsed.notifiers?.["discord"]).toMatchObject({ + webhookUrl: "https://discord.com/api/webhooks/new/new", + username: "AO", + avatarUrl: "https://example.com/avatar.png", + threadId: "111", + retries: 5, + retryDelayMs: 3000, + }); + }); + + it("shows status without writing config", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: discord + webhookUrl: https://discord.com/api/webhooks/123/secret +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync(["node", "test", "setup", "discord", "--status"]); + + expect(mockFetch).toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("fails on conflicting Discord notifier config in non-interactive mode", async () => { + mockReadFileSync.mockReturnValue(` +port: 3000 +notifiers: + discord: + plugin: webhook +projects: + my-app: + name: my-app +`); + const program = createProgram(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + await expect( + program.parseAsync([ + "node", + "test", + "setup", + "discord", + "--webhook-url", + "https://discord.com/api/webhooks/123/secret", + "--non-interactive", + ]), + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/__tests__/lib/notify-test.test.ts b/packages/cli/__tests__/lib/notify-test.test.ts new file mode 100644 index 000000000..f93de3d18 --- /dev/null +++ b/packages/cli/__tests__/lib/notify-test.test.ts @@ -0,0 +1,304 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { + closeDb, + readObservabilitySummary, + type Notifier, + type OrchestratorConfig, + type OrchestratorEvent, + type PluginRegistry, +} from "@aoagents/ao-core"; +import { + addSinkNotifierConfig, + createNotifyTestEvent, + parseNotifyDataJson, + resolveNotifyTestTargets, + runNotifyTest, + startNotifySink, +} from "../../src/lib/notify-test.js"; + +function makeConfig(overrides: Partial = {}): OrchestratorConfig { + const testHome = process.env["HOME"] ?? process.env["USERPROFILE"] ?? tmpdir(); + return { + configPath: join(testHome, "agent-orchestrator.yaml"), + readyThresholdMs: 300_000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["alerts"], + }, + projects: { + demo: { + name: "Demo", + path: "/tmp/demo", + defaultBranch: "main", + sessionPrefix: "demo", + }, + }, + notifiers: { + alerts: { plugin: "slack" }, + ops: { plugin: "slack" }, + }, + notificationRouting: { + urgent: ["alerts"], + action: ["alerts", "ops"], + warning: ["ops"], + info: ["alerts"], + }, + reactions: {}, + ...overrides, + }; +} + +function makeRegistry(notifiers: Record | undefined>): PluginRegistry { + return { + register: vi.fn(), + get: vi.fn((slot: string, name: string) => { + if (slot !== "notifier") return null; + return notifiers[name] ?? null; + }), + list: vi.fn(() => []), + loadBuiltins: vi.fn(), + loadFromConfig: vi.fn(), + } as unknown as PluginRegistry; +} + +describe("notify test helper", () => { + let tempRoot: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + + beforeEach(() => { + tempRoot = join(tmpdir(), `ao-notify-test-${randomUUID()}`); + mkdirSync(tempRoot, { recursive: true }); + originalHome = process.env["HOME"]; + originalUserProfile = process.env["USERPROFILE"]; + process.env["HOME"] = tempRoot; + process.env["USERPROFILE"] = tempRoot; + }); + + afterEach(() => { + closeDb(); + vi.restoreAllMocks(); + if (originalHome === undefined) { + delete process.env["HOME"]; + } else { + process.env["HOME"] = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env["USERPROFILE"]; + } else { + process.env["USERPROFILE"] = originalUserProfile; + } + rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + it("builds realistic CI and PR template data", () => { + const { event } = createNotifyTestEvent({ templateName: "ci-failing" }); + + expect(event.type).toBe("ci.failing"); + expect(event.priority).toBe("action"); + expect(event.data).toMatchObject({ + schemaVersion: 3, + semanticType: "ci.failing", + subject: { + pr: { + number: 1579, + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + }, + }, + ci: { + status: "failing", + failedChecks: [ + { name: "typecheck", status: "failed" }, + { name: "unit-tests", status: "failed" }, + ], + }, + }); + expect(event.data.prUrl).toBeUndefined(); + }); + + it("merges valid --data JSON and rejects invalid JSON", () => { + expect(parseNotifyDataJson('{"runId":"abc","attempt":2}')).toEqual({ + runId: "abc", + attempt: 2, + }); + expect(() => parseNotifyDataJson("{bad json")).toThrow("Invalid --data JSON"); + expect(() => parseNotifyDataJson('"not an object"')).toThrow("--data must be a JSON object"); + }); + + it("resolves aliases and falls back to plugin-name registry lookup", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + const registry = makeRegistry({ + slack: { name: "slack", notify }, + }); + + const config = makeConfig(); + const result = await runNotifyTest(config, registry, { to: ["alerts"] }); + + expect(result.ok).toBe(true); + expect(result.targets).toEqual([{ reference: "alerts", pluginName: "slack" }]); + expect(registry.get).toHaveBeenCalledWith("notifier", "alerts"); + expect(registry.get).toHaveBeenCalledWith("notifier", "slack"); + expect(notify).toHaveBeenCalledTimes(1); + + const summary = readObservabilitySummary(config); + expect(summary.projects["demo"]?.metrics["notification_delivery"]?.success).toBe(1); + }); + + it("resolves explicit routes through notificationRouting before defaults", () => { + const targets = resolveNotifyTestTargets(makeConfig(), "info", { route: "action" }); + + expect(targets).toEqual([ + { reference: "alerts", pluginName: "slack" }, + { reference: "ops", pluginName: "slack" }, + ]); + }); + + it("deduplicates --all targets across configured, default, and routed refs", () => { + const config = makeConfig({ + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["alerts", "ops"], + }, + notificationRouting: { + urgent: ["alerts"], + action: ["ops"], + warning: ["alerts", "ops"], + info: ["alerts"], + }, + }); + + const targets = resolveNotifyTestTargets(config, "info", { all: true }); + + expect(targets.map((target) => target.reference)).toEqual(["alerts", "ops"]); + }); + + it("does not send in dry-run mode", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + const registry = makeRegistry({ + alerts: { name: "alerts", notify }, + }); + + const result = await runNotifyTest(makeConfig(), registry, { dryRun: true }); + + expect(result.ok).toBe(true); + expect(result.deliveries[0]?.status).toBe("dry_run"); + expect(notify).not.toHaveBeenCalled(); + expect(readObservabilitySummary(makeConfig()).projects["demo"]).toBeUndefined(); + }); + + it("uses notifyWithActions when available", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + const notifyWithActions = vi.fn().mockResolvedValue(undefined); + const registry = makeRegistry({ + alerts: { name: "alerts", notify, notifyWithActions }, + }); + + const result = await runNotifyTest(makeConfig(), registry, { actions: true }); + + expect(result.ok).toBe(true); + expect(result.deliveries[0]?.method).toBe("notifyWithActions"); + expect(notifyWithActions).toHaveBeenCalledTimes(1); + expect(notify).not.toHaveBeenCalled(); + }); + + it("warns and falls back to notify when actions are unsupported", async () => { + const notify = vi.fn().mockResolvedValue(undefined); + const registry = makeRegistry({ + alerts: { name: "alerts", notify }, + }); + + const result = await runNotifyTest(makeConfig(), registry, { actions: true }); + + expect(result.ok).toBe(true); + expect(result.deliveries[0]?.method).toBe("notify"); + expect(result.warnings[0]).toContain("notifyWithActions() is unavailable"); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it("continues after partial delivery failures", async () => { + const failing = vi.fn().mockRejectedValue(new Error("webhook failed")); + const passing = vi.fn().mockResolvedValue(undefined); + const registry = makeRegistry({ + alerts: { name: "alerts", notify: failing }, + ops: { name: "ops", notify: passing }, + }); + + const config = makeConfig(); + const result = await runNotifyTest(config, registry, { route: "action" }); + + expect(result.ok).toBe(false); + expect(failing).toHaveBeenCalledTimes(1); + expect(passing).toHaveBeenCalledTimes(1); + expect(result.deliveries.map((delivery) => delivery.status)).toEqual(["failed", "sent"]); + const summary = readObservabilitySummary(config); + expect(summary.projects["demo"]?.metrics["notification_delivery"]).toMatchObject({ + success: 1, + failure: 1, + }); + }); + + it("reports unresolved targets and no-target configs as failures", async () => { + const unresolved = await runNotifyTest(makeConfig(), makeRegistry({}), { to: ["missing"] }); + expect(unresolved.ok).toBe(false); + expect(unresolved.deliveries[0]?.status).toBe("unresolved"); + + const noTargets = await runNotifyTest( + makeConfig({ + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: [], + }, + notifiers: {}, + notificationRouting: { + urgent: [], + action: [], + warning: [], + info: [], + }, + }), + makeRegistry({}), + ); + expect(noTargets.ok).toBe(false); + expect(noTargets.errors[0]).toContain("No notifier targets resolved"); + }); + + it("captures a local webhook sink payload", async () => { + const sink = await startNotifySink(0); + try { + const config = addSinkNotifierConfig(makeConfig({ notifiers: {} }), sink.url); + const notify = vi.fn(async (event: OrchestratorEvent) => { + await fetch(sink.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "notification", event }), + }); + }); + const registry = makeRegistry({ + sink: { name: "sink", notify }, + }); + + const result = await runNotifyTest(config, registry, { to: ["sink"] }); + const request = await sink.waitForRequest(); + + expect(result.ok).toBe(true); + expect(request?.json).toMatchObject({ + type: "notification", + event: { + message: "Test notification from ao notify test", + }, + }); + } finally { + await sink.close(); + } + }); +}); diff --git a/packages/cli/__tests__/program.test.ts b/packages/cli/__tests__/program.test.ts index 5af2ac638..b33691c1d 100644 --- a/packages/cli/__tests__/program.test.ts +++ b/packages/cli/__tests__/program.test.ts @@ -11,6 +11,11 @@ describe("createProgram", () => { expect(createProgram().commands.some((command) => command.name() === "project")).toBe(true); }); + it("registers the notify command", () => { + const notify = createProgram().commands.find((command) => command.name() === "notify"); + expect(notify?.commands.some((command) => command.name() === "test")).toBe(true); + }); + it("registers the review command", () => { expect(createProgram().commands.some((command) => command.name() === "review")).toBe(true); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 99bd6290d..803046b21 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@aoagents/ao-core": "workspace:*", + "@aoagents/ao-notifier-macos": "workspace:*", "@aoagents/ao-plugin-agent-aider": "workspace:*", "@aoagents/ao-plugin-agent-claude-code": "workspace:*", "@aoagents/ao-plugin-agent-codex": "workspace:*", @@ -41,6 +42,7 @@ "@aoagents/ao-plugin-agent-kimicode": "workspace:*", "@aoagents/ao-plugin-agent-opencode": "workspace:*", "@aoagents/ao-plugin-notifier-composio": "workspace:*", + "@aoagents/ao-plugin-notifier-dashboard": "workspace:*", "@aoagents/ao-plugin-notifier-desktop": "workspace:*", "@aoagents/ao-plugin-notifier-discord": "workspace:*", "@aoagents/ao-plugin-notifier-openclaw": "workspace:*", @@ -57,14 +59,16 @@ "@aoagents/ao-plugin-workspace-worktree": "workspace:*", "@aoagents/ao-web": "workspace:*", "@clack/prompts": "^0.9.1", + "@composio/core": "^0.9.0", "chalk": "^5.4.0", "commander": "^13.0.0", "ora": "^8.1.0", - "yaml": "^2.7.0" + "yaml": "^2.7.0", + "zod": "^3.25.76" }, "devDependencies": { - "@vitest/coverage-v8": "^3.0.0", "@types/node": "^25.2.3", + "@vitest/coverage-v8": "^3.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index e1ba54f50..15817b99a 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -8,11 +8,11 @@ import { getObservabilityBaseDir, loadConfig, resolveNotifierTarget, - type Notifier, type OrchestratorConfig, type PluginRegistry, type PluginSlot, } from "@aoagents/ao-core"; +import { runNotifyTest } from "../lib/notify-test.js"; import { runRepoScript } from "../lib/script-runner.js"; import { detectOpenClawInstallation, validateToken } from "../lib/openclaw-probe.js"; import { importPluginModuleFromSource } from "../lib/plugin-store.js"; @@ -338,54 +338,34 @@ async function sendTestNotifications( registry: PluginRegistry, fail: (msg: string) => void, ): Promise { - const activeNotifierNames = config.defaults?.notifiers ?? []; - const targets = new Map>(); + const result = await runNotifyTest(config, registry, { + templateName: "basic", + all: true, + message: "Test notification from ao doctor --test-notify", + sessionId: "doctor-test", + projectId: "doctor", + data: { source: "ao-doctor" }, + }); - for (const name of Object.keys(config.notifiers ?? {})) { - targets.set(name, resolveNotifierTarget(config, name)); - } - - for (const name of activeNotifierNames) { - const target = resolveNotifierTarget(config, name); - if (!targets.has(target.reference)) { - targets.set(target.reference, target); - } - } - - if (targets.size === 0) { + if (result.targets.length === 0) { warn("No notifiers to test. Fix: configure notifiers in your agent-orchestrator.yaml"); return; } - console.log(`\nSending test notification to ${targets.size} notifier(s)...\n`); + console.log(`\nSending test notification to ${result.targets.length} notifier(s)...\n`); - for (const target of targets.values()) { - const notifier = - registry.get("notifier", target.reference) ?? - registry.get("notifier", target.pluginName); - if (!notifier) { - warn(`${target.reference}: plugin "${target.pluginName}" not loaded (may not be installed)`); - continue; + for (const delivery of result.deliveries) { + if (delivery.status === "sent") { + pass(`${delivery.reference}: test notification sent`); + } else if (delivery.status === "unresolved") { + warn(`${delivery.reference}: plugin "${delivery.pluginName}" not loaded (may not be installed)`); + } else if (delivery.error) { + fail(delivery.error); } + } - try { - const testEvent = { - id: `doctor-test-${Date.now()}`, - type: "summary.all_complete" as const, - priority: "info" as const, - sessionId: "doctor-test", - projectId: "doctor", - timestamp: new Date(), - message: "Test notification from ao doctor --test-notify", - data: { source: "ao-doctor" }, - }; - - await notifier.notify(testEvent); - pass(`${target.reference}: test notification sent`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - fail(`${target.reference}: ${message}`); - } + for (const warning of result.warnings) { + warn(warning); } } diff --git a/packages/cli/src/commands/notify.ts b/packages/cli/src/commands/notify.ts new file mode 100644 index 000000000..e916d24cd --- /dev/null +++ b/packages/cli/src/commands/notify.ts @@ -0,0 +1,197 @@ +import type { Command } from "commander"; +import chalk from "chalk"; +import { + createPluginRegistry, + findConfigFile, + loadConfig, + type OrchestratorConfig, + type PluginRegistry, +} from "@aoagents/ao-core"; +import { importPluginModuleFromSource } from "../lib/plugin-store.js"; +import { + addSinkNotifierConfig, + parseNotifyDataJson, + parseNotifyRefs, + parseSinkPort, + runNotifyTest, + startNotifySink, + type NotifySinkServer, + type NotifyTestRequest, + type NotifyTestResult, +} from "../lib/notify-test.js"; + +interface NotifyTestCommandOptions { + template?: string; + to?: string; + all?: boolean; + route?: string; + actions?: boolean; + message?: string; + session?: string; + project?: string; + priority?: string; + type?: string; + data?: string; + dryRun?: boolean; + json?: boolean; + sink?: true | string; +} + +async function loadNotifierRegistry(config: OrchestratorConfig): Promise { + const registry = createPluginRegistry(); + await registry.loadFromConfig(config, importPluginModuleFromSource); + return registry; +} + +function printJson(result: NotifyTestResult, sinkRequests?: unknown[]): void { + console.log( + JSON.stringify( + { + ...result, + sinkRequests, + }, + null, + 2, + ), + ); +} + +function printHumanResult(result: NotifyTestResult, sinkRequests?: unknown[]): void { + console.log( + `${result.dryRun ? "Dry run" : "Sent"} ${result.templateName} notification (${result.event.type}, ${result.event.priority})`, + ); + console.log(`Event id: ${result.event.id}`); + console.log(`Session: ${result.event.projectId}/${result.event.sessionId}`); + + if (result.targets.length > 0) { + console.log(""); + console.log("Targets:"); + for (const target of result.targets) { + console.log(` ${target.reference} -> ${target.pluginName}`); + } + } + + if (result.deliveries.length > 0) { + console.log(""); + console.log("Delivery:"); + for (const delivery of result.deliveries) { + if (delivery.status === "sent") { + console.log(` ${chalk.green("PASS")} ${delivery.reference}: ${delivery.method}`); + } else if (delivery.status === "dry_run") { + console.log(` ${chalk.cyan("DRY")} ${delivery.reference}: ${delivery.method}`); + } else { + console.log(` ${chalk.red("FAIL")} ${delivery.reference}: ${delivery.error}`); + } + } + } + + for (const warning of result.warnings) { + console.log(`${chalk.yellow("WARN")} ${warning}`); + } + + for (const error of result.errors) { + console.error(`${chalk.red("FAIL")} ${error}`); + } + + if (sinkRequests && sinkRequests.length > 0) { + console.log(""); + console.log("Sink received:"); + console.log(JSON.stringify(sinkRequests[0], null, 2)); + } +} + +function commandRequest(opts: NotifyTestCommandOptions, forceSinkTarget: boolean): NotifyTestRequest { + const request: NotifyTestRequest = { + templateName: opts.template, + to: forceSinkTarget ? ["sink"] : parseNotifyRefs(opts.to), + all: opts.all, + route: opts.route, + actions: opts.actions, + message: opts.message, + sessionId: opts.session, + projectId: opts.project, + priority: opts.priority, + type: opts.type, + data: parseNotifyDataJson(opts.data), + dryRun: opts.dryRun, + }; + + return request; +} + +export function registerNotify(program: Command): void { + const notify = program.command("notify").description("Work with configured notification targets"); + + notify + .command("test") + .description("Send a manual demo notification without spawning sessions") + .option("--template ", "Demo template to send", "basic") + .option("--to ", "Comma-separated notifier refs to target") + .option("--all", "Send to all configured, default, and routed notifier refs") + .option("--route ", "Send through a priority route") + .option("--actions", "Send demo actions when supported") + .option("--message ", "Override the notification message") + .option("--session ", "Override the demo session id") + .option("--project ", "Override the demo project id") + .option("--priority ", "Override the event priority") + .option("--type ", "Override the event type") + .option("--data ", "Merge JSON object into the event data") + .option("--dry-run", "Resolve and print the notification without sending it") + .option("--json", "Print structured JSON output") + .option("--sink [port]", "Add an in-memory local webhook target named sink") + .action(async (opts: NotifyTestCommandOptions) => { + let sink: NotifySinkServer | undefined; + let exitCode = 0; + + try { + const sinkPort = parseSinkPort(opts.sink); + const forceSinkTarget = sinkPort !== undefined && !opts.to && !opts.all && !opts.route; + const request = commandRequest(opts, forceSinkTarget); + + const configPath = findConfigFile(); + if (!configPath) { + throw new Error("No config file found. Cannot test notifiers without agent-orchestrator.yaml"); + } + + let config: OrchestratorConfig = loadConfig(configPath); + if (sinkPort !== undefined) { + const sinkUrl = opts.dryRun ? `http://127.0.0.1:${sinkPort}` : undefined; + if (!opts.dryRun) { + sink = await startNotifySink(sinkPort); + } + config = addSinkNotifierConfig(config, sink?.url ?? sinkUrl ?? "http://127.0.0.1:0"); + } + + const registry = await loadNotifierRegistry(config); + const result = await runNotifyTest(config, registry, request); + const sinkRequest = sink ? await sink.waitForRequest(1000) : null; + const sinkRequests = sinkRequest ? [sinkRequest] : sink?.requests; + + if (opts.json) { + printJson(result, sinkRequests); + } else { + printHumanResult(result, sinkRequests); + } + + if (!result.ok) { + exitCode = 1; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (opts.json) { + console.log(JSON.stringify({ ok: false, errors: [message] }, null, 2)); + } else { + console.error(`${chalk.red("FAIL")} ${message}`); + } + exitCode = 1; + } finally { + if (sink) { + await sink.close(); + } + } + + if (exitCode !== 0) { + process.exit(exitCode); + } + }); +} diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index fa2adcc1e..b5c5da2ab 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -509,38 +509,51 @@ export function registerPlugin(program: Command): void { ) .option( "--token ", - "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)", + "Remote/manual OpenClaw token fallback (passed to setup when installing notifier-openclaw)", ) - .action(async (reference: string, opts: { url?: string; token?: string }) => { - const configPath = findConfigFile(); - if (!configPath) { - throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first."); - } - - const config = loadConfig(configPath); - const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath); - const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier); - const previousPlugins = config.plugins ?? []; - const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor); - writePluginsConfig(configPath, nextPlugins); - - console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath))); - - if (setupAction === "openclaw-setup") { - // Always run setup — interactive in TTY, auto-detect in non-TTY. - // Non-interactive setup will auto-detect OpenClaw on localhost if - // no --url is given and the gateway is reachable. - try { - await runSetupAction({ url: opts.url, token: opts.token }); - } catch (err) { - // Rollback: restore previous plugin list so a failed setup - // doesn't leave a half-configured notifier enabled in config. - writePluginsConfig(configPath, previousPlugins); - console.log(chalk.dim("Rolled back plugin config after setup failure.")); - throw err; + .option( + "--openclaw-config-path ", + "OpenClaw config path that contains hooks.token (passed to setup when installing notifier-openclaw)", + ) + .action( + async ( + reference: string, + opts: { url?: string; token?: string; openclawConfigPath?: string }, + ) => { + const configPath = findConfigFile(); + if (!configPath) { + throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first."); } - } - }); + + const config = loadConfig(configPath); + const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath); + const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier); + const previousPlugins = config.plugins ?? []; + const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor); + writePluginsConfig(configPath, nextPlugins); + + console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath))); + + if (setupAction === "openclaw-setup") { + // Always run setup — interactive in TTY, auto-detect in non-TTY. + // Non-interactive setup will auto-detect OpenClaw on localhost if + // no --url is given and the gateway is reachable. + try { + await runSetupAction({ + url: opts.url, + token: opts.token, + openclawConfigPath: opts.openclawConfigPath, + }); + } catch (err) { + // Rollback: restore previous plugin list so a failed setup + // doesn't leave a half-configured notifier enabled in config. + writePluginsConfig(configPath, previousPlugins); + console.log(chalk.dim("Rolled back plugin config after setup failure.")); + throw err; + } + } + }, + ); plugin .command("update") diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 0ae91e5e9..9be56d4f9 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -1,497 +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 { recordActivityEvent } from "@aoagents/ao-core"; import { - CONFIG_SCHEMA_URL, - findConfigFile, - isCanonicalGlobalConfigPath, - recordActivityEvent, -} from "@aoagents/ao-core"; + DesktopSetupError, + runDesktopSetupAction, + type DesktopSetupOptions, +} from "../lib/desktop-setup.js"; import { - probeGateway, - validateToken, - detectOpenClawInstallation, - DEFAULT_OPENCLAW_URL, - HOOKS_PATH, -} from "../lib/openclaw-probe.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export class SetupAbortedError extends Error { - constructor( - message: string, - public readonly exitCode: number = 1, - ) { - super(message); - this.name = "SetupAbortedError"; - } -} - -interface SetupOptions { - url?: string; - token?: string; - nonInteractive?: boolean; - routingPreset?: OpenClawRoutingPreset; -} - -type OpenClawRoutingPreset = "urgent-only" | "urgent-action" | "all"; - -interface ResolvedConfig { - url: string; - token: string; - routingPreset: OpenClawRoutingPreset; -} - -const OPENCLAW_ROUTING_PRESETS = { - "urgent-only": ["urgent"], - "urgent-action": ["urgent", "action"], - all: ["urgent", "action", "warning", "info"], -} as const; - -const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const; - -function isRoutingPreset(value: string | undefined): value is OpenClawRoutingPreset { - return value === "urgent-only" || value === "urgent-action" || value === "all"; -} - -function normalizeOpenClawHooksUrl(url: string): string { - const normalized = url.trim().replace(/\/+$/, ""); - return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`; -} - -// --------------------------------------------------------------------------- -// Interactive prompts (dynamic import to keep @clack/prompts optional) -// --------------------------------------------------------------------------- - -async function interactiveSetup(existingUrl?: string): Promise { - const clack = await import("@clack/prompts"); - - clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw "))); - - // --- Step 1: Gateway URL --------------------------------------------------- - const defaultUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`; - let detectedUrl: string | undefined; - - const spin = clack.spinner(); - spin.start("Detecting OpenClaw gateway on localhost..."); - - const probe = await probeGateway(DEFAULT_OPENCLAW_URL); - if (probe.reachable) { - detectedUrl = defaultUrl; - spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`); - } else { - spin.stop("No OpenClaw gateway detected on localhost"); - } - - const urlInput = await clack.text({ - message: "OpenClaw webhook URL:", - placeholder: defaultUrl, - initialValue: existingUrl ?? detectedUrl ?? defaultUrl, - validate: (v) => { - if (!v) return "URL is required"; - if (!v.startsWith("http://") && !v.startsWith("https://")) - return "Must start with http:// or https://"; - }, - }); - - if (clack.isCancel(urlInput)) { - clack.cancel("Setup cancelled."); - throw new SetupAbortedError("Setup cancelled.", 0); - } - - // Normalize: ensure URL ends with /hooks/agent - const url = normalizeOpenClawHooksUrl(urlInput as string); - - // --- Step 2: Token --------------------------------------------------------- - const envToken = process.env["OPENCLAW_HOOKS_TOKEN"]; - let tokenValue: string; - - if (envToken) { - const useEnv = await clack.confirm({ - message: `Found OPENCLAW_HOOKS_TOKEN in environment. Use it?`, - initialValue: true, - }); - - if (clack.isCancel(useEnv)) { - clack.cancel("Setup cancelled."); - throw new SetupAbortedError("Setup cancelled.", 0); - } - - if (useEnv) { - tokenValue = envToken; - } else { - const input = await clack.password({ - message: "Enter your OpenClaw hooks token:", - validate: (v) => (!v ? "Token is required" : undefined), - }); - if (clack.isCancel(input)) { - clack.cancel("Setup cancelled."); - throw new SetupAbortedError("Setup cancelled.", 0); - } - tokenValue = input as string; - } - } else { - const generatedToken = randomBytes(32).toString("base64url"); - const tokenChoice = await clack.select({ - message: "How would you like to set the hooks token?", - options: [ - { value: "generate", label: "Auto-generate a secure token (recommended)" }, - { value: "manual", label: "Enter an existing token manually" }, - ], - }); - - if (clack.isCancel(tokenChoice)) { - clack.cancel("Setup cancelled."); - throw new SetupAbortedError("Setup cancelled.", 0); - } - - if (tokenChoice === "manual") { - const input = await clack.password({ - message: "Enter your OpenClaw hooks token:", - validate: (v) => (!v ? "Token is required" : undefined), - }); - if (clack.isCancel(input)) { - clack.cancel("Setup cancelled."); - throw new SetupAbortedError("Setup cancelled.", 0); - } - tokenValue = input as string; - } else { - tokenValue = generatedToken; - clack.log.success(`Generated token: ${chalk.dim(tokenValue.slice(0, 8))}...`); - } - } - - // --- Step 3: Validate ------------------------------------------------------ - spin.start("Validating token against gateway..."); - - const validation = await validateToken(url, tokenValue); - if (validation.valid) { - spin.stop("Token validated — connection works!"); - } else { - spin.stop(`Validation failed: ${validation.error}`); - - const cont = await clack.confirm({ - message: "Save config anyway? (you can fix the token later)", - initialValue: false, - }); - - if (clack.isCancel(cont) || !cont) { - clack.cancel("Setup cancelled. Fix the issue and retry."); - throw new SetupAbortedError("Setup cancelled. Fix the issue and retry."); - } - } - - const routingPreset = await clack.select({ - message: "Which notifications should OpenClaw receive?", - initialValue: "urgent-action", - options: [ - { value: "urgent-action", label: "Urgent + action (recommended)" }, - { value: "urgent-only", label: "Urgent only" }, - { value: "all", label: "All priorities" }, - ], - }); - - if (clack.isCancel(routingPreset)) { - clack.cancel("Setup cancelled."); - throw new SetupAbortedError("Setup cancelled.", 0); - } - - return { - url, - token: tokenValue, - routingPreset: routingPreset as OpenClawRoutingPreset, - }; -} - -// --------------------------------------------------------------------------- -// Non-interactive path -// --------------------------------------------------------------------------- - -async function nonInteractiveSetup(opts: SetupOptions): Promise { - let rawUrl = opts.url ?? process.env["OPENCLAW_GATEWAY_URL"]; - const token = opts.token ?? process.env["OPENCLAW_HOOKS_TOKEN"]; - - // Auto-detect OpenClaw if no URL was given explicitly - if (!rawUrl) { - const installation = await detectOpenClawInstallation(); - if (installation.state === "running") { - rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`; - console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`)); - } else { - throw new SetupAbortedError( - "Error: OpenClaw gateway not reachable and no --url provided.\n" + - " Start OpenClaw first, or pass --url explicitly:\n" + - " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive", - ); - } - } - - let url = rawUrl; - if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new SetupAbortedError("Error: --url must start with http:// or https://"); - } - - // Normalize: ensure URL ends with /hooks/agent - url = normalizeOpenClawHooksUrl(url); - - const resolvedToken = token ?? randomBytes(32).toString("base64url"); - if (!token) { - console.log(chalk.dim("No token provided — auto-generated a secure token.")); - } - - // Skip pre-write validation — on fresh installs the gateway won't have the - // token yet. We write both configs first, then the user restarts the gateway. - console.log(chalk.dim("Skipping pre-validation (token will be written to both configs).")); - - const routingPreset = isRoutingPreset(opts.routingPreset) ? opts.routingPreset : "urgent-action"; - - return { url, token: resolvedToken, routingPreset }; -} - -// --------------------------------------------------------------------------- -// Config writer -// --------------------------------------------------------------------------- - -function writeOpenClawConfig( - configPath: string, - resolved: ResolvedConfig, - nonInteractive: boolean, -): void { - const rawYaml = readFileSync(configPath, "utf-8"); - - // Use parseDocument to preserve YAML comments during round-trip - const doc = parseDocument(rawYaml); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rawConfig = (doc.toJS() as Record) ?? {}; - - // Write the env-var placeholder so the raw token is never committed to - // version control. ao setup openclaw exports the real value to the shell - // profile; the notifier plugin resolves it at runtime (env var → openclaw.json - // fallback for daemon contexts where the shell profile isn't sourced). - if (!rawConfig.notifiers) rawConfig.notifiers = {}; - rawConfig.notifiers.openclaw = { - plugin: "openclaw", - url: resolved.url, - token: "$" + "{OPENCLAW_HOOKS_TOKEN}", // env-var placeholder, not a JS template - retries: 3, - retryDelayMs: 1000, - wakeMode: "now", - }; - - // Add "openclaw" to defaults.notifiers if not already present - if (!rawConfig.defaults) rawConfig.defaults = {}; - if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = []; - if (!Array.isArray(rawConfig.defaults.notifiers)) { - rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers]; - } - if (!rawConfig.defaults.notifiers.includes("openclaw")) { - rawConfig.defaults.notifiers.push("openclaw"); - } - - const defaultsWithoutOpenClaw = (rawConfig.defaults.notifiers as string[]).filter( - (name) => name !== "openclaw", - ); - - // AO routes notifications by priority. Preserve existing notifiers and only - // adjust OpenClaw membership across the standard priority buckets. - if (!rawConfig.notificationRouting) { - const base = [...new Set(defaultsWithoutOpenClaw)]; - rawConfig.notificationRouting = { - urgent: [...base], - action: [...base], - warning: [...base], - info: [...base], - }; - } else if (typeof rawConfig.notificationRouting === "object") { - for (const priority of NOTIFICATION_PRIORITIES) { - if (!Array.isArray(rawConfig.notificationRouting[priority])) { - rawConfig.notificationRouting[priority] = []; - } - } - } - - const selectedPriorities = new Set(OPENCLAW_ROUTING_PRESETS[resolved.routingPreset]); - for (const priority of NOTIFICATION_PRIORITIES) { - const list = rawConfig.notificationRouting[priority] as string[]; - rawConfig.notificationRouting[priority] = list.filter((name) => name !== "openclaw"); - if (selectedPriorities.has(priority)) { - rawConfig.notificationRouting[priority].push("openclaw"); - } - } - - // Update the document tree from the modified plain object while preserving comments - if (!isCanonicalGlobalConfigPath(configPath)) { - const currentSchema = doc.get("$schema"); - if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { - doc.set("$schema", CONFIG_SCHEMA_URL); - } - } - doc.setIn(["notifiers"], rawConfig.notifiers); - doc.setIn(["defaults"], rawConfig.defaults); - doc.setIn(["notificationRouting"], rawConfig.notificationRouting); - - writeFileSync(configPath, doc.toString({ indent: 2 })); - - if (nonInteractive) { - console.log(chalk.green(`✓ Config written to ${configPath}`)); - } -} - -/** - * Write the hooks block into ~/.openclaw/openclaw.json. - * Returns true on success, false on failure (caller should fall back to - * printing manual instructions). - */ -function writeOpenClawJsonConfig(token: string): boolean { - try { - const openclawDir = join(homedir(), ".openclaw"); - const openclawJsonPath = join(openclawDir, "openclaw.json"); - - let config: Record = {}; - if (existsSync(openclawJsonPath)) { - const raw = readFileSync(openclawJsonPath, "utf-8"); - config = JSON.parse(raw) as Record; - } else if (!existsSync(openclawDir)) { - mkdirSync(openclawDir, { recursive: true }); - } - - // Merge the hooks block (preserve other existing keys in hooks if any) - const existingHooks = (config.hooks as Record | undefined) ?? {}; - const existingPrefixes = Array.isArray(existingHooks.allowedSessionKeyPrefixes) - ? existingHooks.allowedSessionKeyPrefixes.filter( - (prefix): prefix is string => typeof prefix === "string", - ) - : []; - const allowedSessionKeyPrefixes = existingPrefixes.includes("hook:") - ? existingPrefixes - : [...existingPrefixes, "hook:"]; - - config.hooks = { - ...existingHooks, - enabled: true, - token, - allowRequestSessionKey: true, - allowedSessionKeyPrefixes, - }; - - writeFileSync(openclawJsonPath, JSON.stringify(config, null, 2) + "\n"); - return true; - } catch { - return false; - } -} - -/** - * Append `export OPENCLAW_HOOKS_TOKEN=...` to the user's shell profile - * (~/.zshrc or ~/.bashrc). Skips if the export line already exists. - * Returns the profile path on success, undefined on failure. - */ -function writeShellExport(token: string): string | undefined { - try { - const shell = process.env["SHELL"] ?? ""; - const profileName = shell.endsWith("/zsh") ? ".zshrc" : ".bashrc"; - const profilePath = join(homedir(), profileName); - - // Sanitize token: escape shell-special characters to prevent injection - // when the profile is sourced. Single-quote the value and escape any - // embedded single quotes (the only character that breaks '...' quoting). - const safeToken = token.replace(/'/g, "'\\''"); - const exportLine = `export OPENCLAW_HOOKS_TOKEN='${safeToken}'`; - - // Check if it already exists (use the same regex for detection and replacement - // to avoid silent no-ops when the line is commented, lacks the export prefix, - // or has leading whitespace) - // Negative lookahead excludes commented lines (e.g. # export OPENCLAW_HOOKS_TOKEN=...) - const existingExportRegex = /^(?!\s*#)\s*(?:export\s+)?OPENCLAW_HOOKS_TOKEN=.*$/m; - if (existsSync(profilePath)) { - const content = readFileSync(profilePath, "utf-8"); - if (existingExportRegex.test(content)) { - // Replace the existing line - const updated = content.replace(existingExportRegex, exportLine); - writeFileSync(profilePath, updated); - return profilePath; - } - } - - // Append - const prefix = existsSync(profilePath) ? "\n" : ""; - writeFileSync(profilePath, `${prefix}# Added by ao setup openclaw\n${exportLine}\n`, { - flag: "a", - }); - return profilePath; - } catch { - return undefined; - } -} - -function printOpenClawInstructions( - nonInteractive: boolean, - openclawConfigWritten: boolean, - shellProfilePath: string | undefined, -): void { - if (openclawConfigWritten) { - // Both configs written automatically - if (nonInteractive) { - console.log( - chalk.green("✓ Both configs written (agent-orchestrator.yaml + ~/.openclaw/openclaw.json)"), - ); - if (shellProfilePath) { - console.log(chalk.green(`✓ OPENCLAW_HOOKS_TOKEN exported in ${shellProfilePath}`)); - } - console.log("Restart OpenClaw gateway to apply."); - } else { - console.log(`\n${chalk.green.bold("Done — both configs written.")}`); - console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block")); - console.log(chalk.dim(" ~/.openclaw/openclaw.json — hooks block")); - if (shellProfilePath) { - console.log(chalk.dim(` ${shellProfilePath} — OPENCLAW_HOOKS_TOKEN export`)); - } - console.log(`\n${chalk.yellow("Restart OpenClaw gateway to apply.")}`); - } - } else { - // Fallback: could not write OpenClaw config, print manual instructions - const instructions = ` -${chalk.bold("OpenClaw-side config required")} - -AO config was written successfully. Add this to your OpenClaw config (${chalk.dim("~/.openclaw/openclaw.json")}): - - ${chalk.cyan(`{ - "hooks": { - "enabled": true, - "token": "", - "allowRequestSessionKey": true, - "allowedSessionKeyPrefixes": ["hook:"] - } - }`)} -`; - - if (nonInteractive) { - console.log("\nOpenClaw-side config required:"); - console.log("AO config was written successfully. Add to ~/.openclaw/openclaw.json:"); - console.log(" hooks.enabled: true"); - console.log(' hooks.token: ""'); - console.log(" hooks.allowRequestSessionKey: true"); - console.log(' hooks.allowedSessionKeyPrefixes: ["hook:"]'); - } else { - console.log(instructions); - } - } -} + 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 @@ -500,20 +50,286 @@ AO config was written successfully. Add this to your OpenClaw config (${chalk.di export function registerSetup(program: Command): void { const setup = program.command("setup").description("Set up integrations with external services"); + setup + .command("dashboard") + .description("Configure dashboard notification retention and routing") + .option("--limit ", "Number of latest notifications to retain for the dashboard") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--refresh", "Refresh/reconfigure dashboard notifier config") + .option("--non-interactive", "Skip prompts") + .option("--force", "Replace a conflicting notifiers.dashboard entry") + .option("--status", "Show dashboard notifier setup status") + .action(async (opts: DashboardSetupOptions) => { + try { + await runDashboardSetupAction(opts); + } catch (err) { + if (err instanceof DashboardSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("desktop") + .description("Install and configure the native macOS desktop notifier") + .option("--backend ", "Desktop backend: auto | ao-app | terminal-notifier | osascript") + .option("--refresh", "Refresh/reconfigure desktop notifier config") + .option("--dashboard-url ", "Dashboard URL to open from notifications") + .option("--app-path ", "Custom AO Notifier.app install path") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--no-test", "Skip the setup test notification") + .option("--non-interactive", "Skip prompts and fail on config conflicts unless --force is set") + .option("--force", "Repair the app install and replace conflicting desktop notifier config") + .option("--status", "Show the native desktop notifier install and permission status") + .option("--uninstall", "Remove AO Notifier.app without changing AO config") + .action(async (opts: DesktopSetupOptions) => { + try { + await runDesktopSetupAction(opts); + } catch (err) { + if (err instanceof DesktopSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("webhook") + .description("Connect AO notifications to a generic HTTP webhook") + .option("--url ", "Webhook URL") + .option("--auth-token ", "Bearer token to store in webhook Authorization header") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--refresh", "Refresh/reconfigure webhook notifier config") + .option("--no-test", "Skip the setup test POST") + .option("--non-interactive", "Skip prompts and require --url unless --refresh can reuse config") + .option("--force", "Replace a conflicting notifiers.webhook entry") + .option("--status", "Show webhook notifier setup status and probe the endpoint") + .action(async (opts: WebhookSetupOptions) => { + try { + await runWebhookSetupAction(opts); + } catch (err) { + if (err instanceof WebhookSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("slack") + .description("Connect AO notifications to a Slack incoming webhook") + .option("--webhook-url ", "Slack incoming webhook URL") + .option( + "--channel ", + "Optional channel name; must match the channel selected for the webhook URL", + ) + .option("--username ", "Slack display name for AO messages") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--refresh", "Refresh/reconfigure Slack notifier config") + .option("--no-test", "Skip the setup test Slack message") + .option( + "--non-interactive", + "Skip prompts and require --webhook-url unless --refresh can reuse config", + ) + .option("--force", "Replace a conflicting notifiers.slack entry") + .option("--status", "Show Slack notifier setup status and probe the endpoint") + .action(async (opts: SlackSetupOptions) => { + try { + await runSlackSetupAction(opts); + } catch (err) { + if (err instanceof SlackSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("discord") + .description("Connect AO notifications to a Discord incoming webhook") + .option("--webhook-url ", "Discord incoming webhook URL") + .option("--username ", "Discord display name for AO messages") + .option("--avatar-url ", "Discord avatar image URL for AO messages") + .option("--thread-id ", "Discord thread id to post into") + .option("--retries ", "Retry count for rate limits, network errors, and 5xx") + .option("--retry-delay-ms ", "Base retry delay in milliseconds") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--refresh", "Refresh/reconfigure Discord notifier config") + .option("--no-test", "Skip the setup test Discord message") + .option( + "--non-interactive", + "Skip prompts and require --webhook-url unless --refresh can reuse config", + ) + .option("--force", "Replace a conflicting notifiers.discord entry") + .option("--status", "Show Discord notifier setup status and probe the endpoint") + .action(async (opts: DiscordSetupOptions) => { + try { + await runDiscordSetupAction(opts); + } catch (err) { + if (err instanceof DiscordSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("composio") + .description("Open the interactive Composio notifier setup hub") + .option("--api-key ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id") + .option("--slack", "Open Slack setup directly") + .option("--discord-webhook", "Open Discord webhook setup directly") + .option("--discord-bot", "Open Discord bot setup directly") + .option("--gmail", "Open Gmail setup directly") + .option("--channel ", "Slack channel name or channel id for scriptable Slack setup") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option( + "--connected-account-id ", + "Existing Composio Slack connected account id for scriptable Slack setup", + ) + .option( + "--wait-ms ", + "How long to wait for a new Slack connection in scriptable setup", + "60000", + ) + .option("--non-interactive", "Skip prompts and fail when multiple accounts need selection") + .option("--status", "Show Composio notifier setup status without changing config") + .option("--force", "Replace a conflicting notifiers.composio entry") + .action(async (opts: ComposioSetupOptions) => { + try { + await runComposioSetupAction(opts); + } catch (err) { + if (err instanceof ComposioSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("composio-slack") + .description("Connect AO notifications to Slack through Composio") + .option("--api-key ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for the Slack connected account") + .option("--channel ", "Slack channel name or channel id") + .option("--connected-account-id ", "Existing Composio Slack connected account id") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--wait-ms ", "How long to wait for a new Slack connection", "60000") + .option("--non-interactive", "Skip prompts and fail when multiple accounts need selection") + .option("--status", "Show Composio Slack setup status without changing config") + .option("--force", "Replace a conflicting notifiers.composio-slack entry") + .action(async (opts: ComposioSetupOptions) => { + try { + await runComposioSlackSetupAction(opts); + } catch (err) { + if (err instanceof ComposioSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("composio-discord") + .description("Connect AO notifications to Discord webhooks through Composio") + .option("--api-key ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for tool execution") + .option("--webhook-url ", "Discord webhook URL") + .option("--connected-account-id ", "Existing Composio Discord webhook connected account id") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--non-interactive", "Skip prompts") + .option("--status", "Show Composio Discord webhook setup status without changing config") + .option("--force", "Replace a conflicting notifiers.composio-discord entry") + .action(async (opts: ComposioDiscordWebhookSetupOptions) => { + try { + await runComposioDiscordWebhookSetupAction(opts); + } catch (err) { + if (err instanceof ComposioSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("composio-discord-bot") + .description("Connect AO notifications to a Discord bot through Composio") + .option("--api-key ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for the Discord connected account") + .option("--channel-id ", "Discord channel id") + .option("--bot-token ", "Discord bot token used once to create the Composio account") + .option("--connected-account-id ", "Existing Composio Discord bot connected account id") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--non-interactive", "Skip prompts") + .option("--status", "Show Composio Discord bot setup status without changing config") + .option("--force", "Replace a conflicting notifiers.composio-discord-bot entry") + .action(async (opts: ComposioDiscordBotSetupOptions) => { + try { + await runComposioDiscordBotSetupAction(opts); + } catch (err) { + if (err instanceof ComposioSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + + setup + .command("composio-mail") + .description("Connect AO notifications to Gmail through Composio") + .option("--api-key ", "Composio API key (otherwise uses COMPOSIO_API_KEY)") + .option("--user-id ", "Composio user id for the Gmail connected account") + .option("--email-to ", "Recipient email address for AO notifications") + .option("--connect", "Print a Composio Gmail connect URL when no account exists") + .option("--auth-config-id ", "Existing Composio Gmail auth config id for --connect") + .option("--connected-account-id ", "Existing Composio Gmail connected account id") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") + .option("--wait-ms ", "How long to wait for a new Gmail connection with --connect", "60000") + .option("--non-interactive", "Skip prompts and fail when multiple accounts need selection") + .option("--status", "Show Composio mail setup status without changing config") + .option("--force", "Replace a conflicting notifiers.composio-mail entry") + .action(async (opts: ComposioMailSetupOptions) => { + try { + await runComposioMailSetupAction(opts); + } catch (err) { + if (err instanceof ComposioSetupError) { + console.error(err.message); + process.exit(err.exitCode); + } + throw err; + } + }); + setup .command("openclaw") .description("Connect AO notifications to an OpenClaw gateway") .option("--url ", "OpenClaw webhook URL (e.g. http://127.0.0.1:18789/hooks/agent)") - .option("--token ", "OpenClaw hooks auth token") .option( - "--routing-preset ", - "OpenClaw routing preset: urgent-only | urgent-action | all", + "--token ", + "Remote/manual fallback token; local setup should read hooks.token from OpenClaw config", ) + .option("--openclaw-config-path ", "OpenClaw config path that contains hooks.token") + .option("--routing-preset ", "Routing preset: urgent-only | urgent-action | all") .option( "--non-interactive", - "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)", + "Skip prompts — auto-detects OpenClaw if --url not provided and reads token from OpenClaw config", ) - .action(async (opts: SetupOptions) => { + .option("--refresh", "Refresh/reconfigure OpenClaw notifier config") + .option("--no-test", "Skip the setup token probe") + .option("--force", "Replace a conflicting notifiers.openclaw entry") + .option("--status", "Show OpenClaw notifier setup status and probe the gateway") + .action(async (opts: OpenClawSetupOptions) => { try { await runSetupAction(opts); } catch (err) { @@ -521,13 +337,13 @@ export function registerSetup(program: Command): void { source: "cli", kind: "cli.setup_failed", level: "error", - summary: `ao setup openclaw failed`, + summary: "ao setup openclaw failed", data: { - aborted: err instanceof SetupAbortedError, + aborted: err instanceof OpenClawSetupError && err.exitCode === 0, errorMessage: err instanceof Error ? err.message : String(err), }, }); - if (err instanceof SetupAbortedError) { + if (err instanceof OpenClawSetupError) { console.error(err.message); process.exit(err.exitCode); } @@ -536,88 +352,6 @@ export function registerSetup(program: Command): void { }); } -export async function runSetupAction(opts: SetupOptions): Promise { - const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; - - // --- Find existing config ------------------------------------------------ - let configPath: string | undefined; - try { - const found = findConfigFile(); - configPath = found ?? undefined; - } catch { - // no config found - } - - if (!configPath) { - throw new SetupAbortedError( - "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", - ); - } - - // --- Check for existing openclaw config ---------------------------------- - const rawYaml = readFileSync(configPath, "utf-8"); - const rawConfig = yamlParse(rawYaml) ?? {}; - const existingOpenClaw = rawConfig?.notifiers?.openclaw; - const existingUrl = existingOpenClaw?.url as string | undefined; - - if (existingOpenClaw && !nonInteractive) { - const clack = await import("@clack/prompts"); - const reconfigure = await clack.confirm({ - message: "OpenClaw is already configured. Reconfigure?", - initialValue: false, - }); - - if (clack.isCancel(reconfigure) || !reconfigure) { - console.log(chalk.dim("Keeping existing config.")); - return; - } - } - - // --- Run setup ----------------------------------------------------------- - let resolved: ResolvedConfig; - - if (nonInteractive) { - resolved = await nonInteractiveSetup(opts); - } else { - resolved = await interactiveSetup(existingUrl); - } - - // --- Write AO config ----------------------------------------------------- - writeOpenClawConfig(configPath, resolved, nonInteractive); - - // --- Write OpenClaw config ----------------------------------------------- - const openclawConfigWritten = writeOpenClawJsonConfig(resolved.token); - if (openclawConfigWritten && nonInteractive) { - console.log(chalk.green("✓ Wrote hooks config to ~/.openclaw/openclaw.json")); - } else if (!openclawConfigWritten) { - recordActivityEvent({ - source: "cli", - kind: "cli.setup_degraded", - level: "warn", - summary: `ao setup openclaw completed without writing ~/.openclaw/openclaw.json`, - data: { reason: "openclaw_json_write_failed" }, - }); - } - - // --- Write shell export -------------------------------------------------- - const shellProfilePath = writeShellExport(resolved.token); - if (shellProfilePath && nonInteractive) { - console.log(chalk.green(`✓ Exported OPENCLAW_HOOKS_TOKEN in ${shellProfilePath}`)); - } - - // --- Print instructions -------------------------------------------------- - printOpenClawInstructions(nonInteractive, openclawConfigWritten, shellProfilePath); - - // --- Done ---------------------------------------------------------------- - if (!nonInteractive) { - const clack = await import("@clack/prompts"); - clack.outro( - `${chalk.green("Setup complete!")} AO will send notifications to OpenClaw.\n` + - chalk.dim(" Run 'ao doctor' to verify the full setup.\n") + - chalk.dim(" Restart AO with 'ao stop && ao start' to activate."), - ); - } else { - console.log(chalk.green("\n✓ OpenClaw setup complete.")); - console.log(chalk.dim("Restart AO to activate: ao stop && ao start")); - } +export async function runSetupAction(opts: OpenClawSetupOptions): Promise { + await runOpenClawSetupAction(opts); } diff --git a/packages/cli/src/lib/composio-setup.ts b/packages/cli/src/lib/composio-setup.ts new file mode 100644 index 000000000..ab565f171 --- /dev/null +++ b/packages/cli/src/lib/composio-setup.ts @@ -0,0 +1,4531 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + ensureNotifierDefault, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + routingLabel, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +const SLACK_TOOLKIT = "slack"; +const DISCORD_TOOLKIT = "discordbot"; +const GMAIL_TOOLKIT = "gmail"; +const DISCORD_TOOL_VERSION = "20260429_01"; +const GMAIL_TOOL_VERSION = "20260506_01"; +const COMPOSIO_NOTIFIER = "composio"; +const COMPOSIO_SLACK_NOTIFIER = "composio-slack"; +const COMPOSIO_DISCORD_WEBHOOK_NOTIFIER = "composio-discord"; +const COMPOSIO_DISCORD_BOT_NOTIFIER = "composio-discord-bot"; +const COMPOSIO_MAIL_NOTIFIER = "composio-mail"; +const DEFAULT_COMPOSIO_USER_ID = "aoagent"; +const GMAIL_SEND_TOOL = "GMAIL_SEND_EMAIL"; +const COMPOSIO_DASHBOARD_URL = "https://app.composio.dev"; +const DISCORD_APP_URL = "https://discord.com/app"; +const DISCORD_DEVELOPER_PORTAL_URL = "https://discord.com/developers/applications"; +const DISCORD_WEBHOOK_DOCS_URL = + "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"; + +export class ComposioSetupError extends Error { + constructor( + message: string, + public readonly exitCode: number = 1, + ) { + super(message); + this.name = "ComposioSetupError"; + } +} + +export interface ComposioSetupOptions { + apiKey?: string; + userId?: string; + channel?: string; + connectedAccountId?: string; + webhookUrl?: string; + channelId?: string; + botToken?: string; + emailTo?: string; + authConfigId?: string; + connect?: boolean; + slack?: boolean; + discordWebhook?: boolean; + discordBot?: boolean; + gmail?: boolean; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + waitMs?: string; + routingPreset?: string; +} + +export interface ComposioDiscordWebhookSetupOptions { + apiKey?: string; + userId?: string; + webhookUrl?: string; + connectedAccountId?: string; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + routingPreset?: string; +} + +export interface ComposioDiscordBotSetupOptions { + apiKey?: string; + userId?: string; + channelId?: string; + botToken?: string; + connectedAccountId?: string; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + routingPreset?: string; +} + +export interface ComposioMailSetupOptions { + apiKey?: string; + userId?: string; + emailTo?: string; + authConfigId?: string; + connectedAccountId?: string; + connect?: boolean; + nonInteractive?: boolean; + status?: boolean; + force?: boolean; + waitMs?: string; + routingPreset?: string; +} + +type ComposioAppChoice = "slack" | "discord-webhook" | "discord-bot" | "gmail"; + +interface ResolvedApiKey { + apiKey: string; + shouldWriteApiKey: boolean; + sourceLabel: string; +} + +interface ConnectedAccount { + id: string; + status?: string; + statusReason?: string | null; + toolkit?: { slug?: string }; + authConfig?: { id?: string; name?: string }; + alias?: string | null; + isDisabled?: boolean; + scopes?: string[]; +} + +interface AuthConfigSummary { + id: string; + toolkit?: { slug?: string }; + toolAccessConfig?: { + toolsAvailableForExecution?: string[]; + toolsForConnectedAccountCreation?: string[]; + }; + restrictToFollowingTools?: string[]; +} + +interface ConnectionRequest { + id?: string; + redirectUrl?: string; + waitForConnection?: (timeout?: number) => Promise; +} + +interface ComposioSetupClient { + connectedAccounts: { + list: (query?: Record) => Promise; + get?: (id: string) => Promise; + link?: ( + userId: string, + authConfigId: string, + options?: Record, + ) => Promise; + initiate?: ( + userId: string, + authConfigId: string, + options?: Record, + ) => Promise; + waitForConnection?: (id: string, timeout?: number) => Promise; + }; + authConfigs?: { + list?: (query?: Record) => Promise; + create?: (toolkit: string, options?: Record) => Promise; + get?: (id: string) => Promise; + retrieve?: (id: string) => Promise; + }; + toolkits?: { + authorize?: (userId: string, toolkitSlug: string, authConfigId?: string) => Promise; + }; +} + +interface ResolvedComposioSetup { + apiKey: string; + shouldWriteApiKey: boolean; + userId: string; + targetName?: string; + channel?: string; + connectedAccountId?: string; + connectionUrl?: string; + routingPreset?: NotifierRoutingPreset; +} + +interface ResolvedDiscordSetup { + apiKey: string; + shouldWriteApiKey: boolean; + userId: string; + mode: "webhook" | "bot"; + targetName: string; + webhookUrl?: string; + channelId?: string; + connectedAccountId?: string; + routingPreset?: NotifierRoutingPreset; +} + +interface ResolvedMailSetup { + apiKey: string; + shouldWriteApiKey: boolean; + userId: string; + emailTo?: string; + connectedAccountId?: string; + connectionUrl?: string; + targetName?: string; + routingPreset?: NotifierRoutingPreset; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) + return value.filter((entry): entry is string => typeof entry === "string"); + if (typeof value === "string") return [value]; + return []; +} + +function resolveComposioRoutingPreset( + value: string | undefined, +): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "Composio") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new ComposioSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function routingReviewLabel(preset: NotifierRoutingPreset | undefined): string { + return preset ? routingLabel(preset) : "unchanged"; +} + +function scopeArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string"); + } + if (typeof value === "string") { + return value + .split(/\s+/) + .map((entry) => entry.trim()) + .filter(Boolean); + } + return []; +} + +function getExistingComposioConfig(rawConfig: Record): Record { + return getExistingNotifierConfig(rawConfig, COMPOSIO_NOTIFIER); +} + +function getExistingNotifierConfig( + rawConfig: Record, + notifierName: string, +): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = isRecord(notifiers[notifierName]) ? notifiers[notifierName] : {}; + return existing; +} + +function resolveApiKeyCandidate( + opts: { apiKey?: string }, + existing: Record, +): ResolvedApiKey | undefined { + const optionKey = stringValue(opts.apiKey); + if (optionKey) { + return { + apiKey: optionKey, + shouldWriteApiKey: true, + sourceLabel: "command option", + }; + } + + const envKey = stringValue(process.env.COMPOSIO_API_KEY); + if (envKey) { + return { + apiKey: envKey, + shouldWriteApiKey: false, + sourceLabel: "COMPOSIO_API_KEY", + }; + } + + const existingKey = stringValue(existing["composioApiKey"]); + if (existingKey && !existingKey.includes("${")) { + return { + apiKey: existingKey, + shouldWriteApiKey: true, + sourceLabel: "agent-orchestrator.yaml", + }; + } + + return undefined; +} + +function resolveApiKey( + opts: { apiKey?: string }, + existing: Record, +): { apiKey?: string; shouldWriteApiKey: boolean } { + const candidate = resolveApiKeyCandidate(opts, existing); + return { + apiKey: candidate?.apiKey, + shouldWriteApiKey: candidate?.shouldWriteApiKey ?? false, + }; +} + +function resolveUserId(opts: { userId?: string }, existing: Record): string { + return ( + stringValue(opts.userId) ?? + stringValue(existing["userId"]) ?? + stringValue(existing["entityId"]) ?? + stringValue(process.env.COMPOSIO_USER_ID) ?? + stringValue(process.env.COMPOSIO_ENTITY_ID) ?? + DEFAULT_COMPOSIO_USER_ID + ); +} + +function isComposioSetupClient(value: unknown): value is ComposioSetupClient { + return ( + isRecord(value) && + isRecord(value["connectedAccounts"]) && + typeof value["connectedAccounts"]["list"] === "function" + ); +} + +async function loadComposioClient(apiKey: string): Promise { + const mod = (await import("@composio/core")) as unknown as Record; + const ComposioClass = (mod.Composio ?? + (mod.default as Record | undefined)?.Composio ?? + mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined; + + if (typeof ComposioClass !== "function") { + throw new ComposioSetupError("Could not find Composio class in @composio/core module."); + } + + const client = new ComposioClass({ apiKey }); + if (!isComposioSetupClient(client)) { + throw new ComposioSetupError("Composio SDK client does not expose connectedAccounts.list()."); + } + + return client; +} + +function toConnectedAccount(value: unknown): ConnectedAccount | null { + if (!isRecord(value)) return null; + const id = stringValue(value["id"]); + if (!id) return null; + const data = isRecord(value["data"]) ? value["data"] : {}; + const params = isRecord(value["params"]) ? value["params"] : {}; + const state = isRecord(value["state"]) ? value["state"] : {}; + const stateVal = isRecord(state["val"]) ? state["val"] : {}; + + return { + id, + status: stringValue(value["status"]), + statusReason: stringValue(value["statusReason"]) ?? stringValue(value["status_reason"]) ?? null, + toolkit: isRecord(value["toolkit"]) + ? { slug: stringValue(value["toolkit"]["slug"]) } + : undefined, + authConfig: isRecord(value["authConfig"]) + ? { + id: stringValue(value["authConfig"]["id"]), + name: stringValue(value["authConfig"]["name"]), + } + : undefined, + alias: stringValue(value["alias"]) ?? null, + isDisabled: value["isDisabled"] === true || value["is_disabled"] === true, + scopes: [ + ...scopeArray(data["scope"]), + ...scopeArray(data["scopes"]), + ...scopeArray(params["scope"]), + ...scopeArray(params["scopes"]), + ...scopeArray(stateVal["scope"]), + ...scopeArray(stateVal["scopes"]), + ], + }; +} + +function toAuthConfigSummary(value: unknown): AuthConfigSummary | null { + if (!isRecord(value)) return null; + const id = stringValue(value["id"]); + if (!id) return null; + const toolkit = isRecord(value["toolkit"]) ? value["toolkit"] : {}; + const toolAccessConfig = isRecord(value["toolAccessConfig"]) + ? value["toolAccessConfig"] + : isRecord(value["tool_access_config"]) + ? value["tool_access_config"] + : {}; + + return { + id, + toolkit: isRecord(toolkit) ? { slug: stringValue(toolkit["slug"]) } : undefined, + toolAccessConfig: { + toolsAvailableForExecution: asStringArray( + toolAccessConfig["toolsAvailableForExecution"] ?? + toolAccessConfig["tools_available_for_execution"], + ), + toolsForConnectedAccountCreation: asStringArray( + toolAccessConfig["toolsForConnectedAccountCreation"] ?? + toolAccessConfig["tools_for_connected_account_creation"], + ), + }, + restrictToFollowingTools: asStringArray( + value["restrictToFollowingTools"] ?? value["restrict_to_following_tools"], + ), + }; +} + +function accountsFromListResult(result: unknown): ConnectedAccount[] { + if (Array.isArray(result)) + return result.map(toConnectedAccount).filter((a): a is ConnectedAccount => a !== null); + if (isRecord(result) && Array.isArray(result["items"])) { + return result["items"].map(toConnectedAccount).filter((a): a is ConnectedAccount => a !== null); + } + if (isRecord(result) && Array.isArray(result["data"])) { + return result["data"].map(toConnectedAccount).filter((a): a is ConnectedAccount => a !== null); + } + return []; +} + +function authConfigsFromListResult(result: unknown): AuthConfigSummary[] { + if (Array.isArray(result)) + return result.map(toAuthConfigSummary).filter((a): a is AuthConfigSummary => a !== null); + if (isRecord(result) && Array.isArray(result["items"])) { + return result["items"] + .map(toAuthConfigSummary) + .filter((a): a is AuthConfigSummary => a !== null); + } + if (isRecord(result) && Array.isArray(result["data"])) { + return result["data"] + .map(toAuthConfigSummary) + .filter((a): a is AuthConfigSummary => a !== null); + } + return []; +} + +function isActive(account: ConnectedAccount): boolean { + if (account.isDisabled) return false; + return !account.status || account.status.toUpperCase() === "ACTIVE"; +} + +function isToolkit(account: ConnectedAccount, toolkit: string): boolean { + return !account.toolkit?.slug || account.toolkit.slug.toLowerCase() === toolkit; +} + +function hasGmailNotifyScopes(account: ConnectedAccount): boolean { + const scopes = new Set(account.scopes ?? []); + if (scopes.has("https://mail.google.com/")) return true; + const canSend = scopes.has("https://www.googleapis.com/auth/gmail.send"); + const canReadProfile = + scopes.has("https://www.googleapis.com/auth/gmail.metadata") || + scopes.has("https://www.googleapis.com/auth/gmail.readonly") || + scopes.has("https://www.googleapis.com/auth/gmail.modify"); + return canSend && canReadProfile; +} + +function authConfigAllowsGmailSend(config: AuthConfigSummary): boolean { + const tools = [ + ...(config.toolAccessConfig?.toolsForConnectedAccountCreation ?? []), + ...(config.toolAccessConfig?.toolsAvailableForExecution ?? []), + ...(config.restrictToFollowingTools ?? []), + ]; + return tools.includes(GMAIL_SEND_TOOL); +} + +async function withConnectedAccountDetails( + client: ComposioSetupClient, + account: ConnectedAccount, +): Promise { + if (!client.connectedAccounts.get) return account; + const detailed = toConnectedAccount(await client.connectedAccounts.get(account.id)); + return detailed ?? account; +} + +async function listActiveSlackAccounts( + client: ComposioSetupClient, + userId: string, +): Promise { + return listActiveToolkitAccounts(client, userId, SLACK_TOOLKIT); +} + +async function listActiveGmailAccounts( + client: ComposioSetupClient, + userId: string, +): Promise { + return listActiveToolkitAccounts(client, userId, GMAIL_TOOLKIT); +} + +async function listUsableGmailAccounts( + client: ComposioSetupClient, + userId: string, +): Promise { + const accounts = await listActiveGmailAccounts(client, userId); + const detailed = await Promise.all( + accounts.map((account) => withConnectedAccountDetails(client, account)), + ); + const usable: ConnectedAccount[] = []; + for (const account of detailed) { + if (await accountCanSendGmail(client, account)) { + usable.push(account); + } + } + return usable; +} + +async function listActiveToolkitAccounts( + client: ComposioSetupClient, + userId: string, + toolkit: string, +): Promise { + const result = await client.connectedAccounts.list({ + userIds: [userId], + toolkitSlugs: [toolkit], + statuses: ["ACTIVE"], + limit: 25, + }); + return accountsFromListResult(result).filter( + (account) => isActive(account) && isToolkit(account, toolkit), + ); +} + +async function verifyConnectedAccount( + client: ComposioSetupClient, + userId: string, + connectedAccountId: string, +): Promise { + return verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + SLACK_TOOLKIT, + "Slack", + () => listActiveSlackAccounts(client, userId), + ); +} + +async function verifyConnectedAccountForToolkit( + client: ComposioSetupClient, + userId: string, + connectedAccountId: string, + toolkit: string, + label: string, + fallbackList?: () => Promise, +): Promise { + const account = client.connectedAccounts.get + ? toConnectedAccount(await client.connectedAccounts.get(connectedAccountId)) + : ((await fallbackList?.())?.find((candidate) => candidate.id === connectedAccountId) ?? null); + + if (!account) { + throw new ComposioSetupError( + `Could not find Composio connected account ${connectedAccountId} for user ${userId}.`, + ); + } + if (!isToolkit(account, toolkit)) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is not a ${label} account.`, + ); + } + if (!isActive(account)) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is not ACTIVE (status: ${account.status ?? "unknown"}).`, + ); + } + return account; +} + +async function getAuthConfig( + client: ComposioSetupClient, + authConfigId: string, +): Promise { + const result = client.authConfigs?.get + ? await client.authConfigs.get(authConfigId) + : client.authConfigs?.retrieve + ? await client.authConfigs.retrieve(authConfigId) + : null; + return toAuthConfigSummary(result); +} + +async function accountCanSendGmail( + client: ComposioSetupClient, + account: ConnectedAccount, +): Promise { + if (hasGmailNotifyScopes(account)) return true; + const authConfigId = account.authConfig?.id; + if (!authConfigId) return false; + const authConfig = await getAuthConfig(client, authConfigId); + return authConfig ? authConfigAllowsGmailSend(authConfig) : false; +} + +async function resolveGmailConnectAuthConfigId( + client: ComposioSetupClient, + explicitAuthConfigId: string | undefined, + nonInteractive: boolean, +): Promise { + if (explicitAuthConfigId) { + const config = await getAuthConfig(client, explicitAuthConfigId); + if (config?.toolkit?.slug && config.toolkit.slug.toLowerCase() !== GMAIL_TOOLKIT) { + throw new ComposioSetupError(`Auth config ${explicitAuthConfigId} is not a Gmail config.`); + } + if (config && !authConfigAllowsGmailSend(config)) { + console.log( + chalk.yellow( + `Auth config ${explicitAuthConfigId} does not explicitly list ${GMAIL_SEND_TOOL}; creating the link anyway.`, + ), + ); + } + return explicitAuthConfigId; + } + + if (!client.authConfigs?.list) { + throw new ComposioSetupError( + "Composio SDK client does not expose authConfigs.list(); pass --auth-config-id.", + ); + } + + const configs = authConfigsFromListResult( + await client.authConfigs.list({ toolkit: GMAIL_TOOLKIT }), + ).filter( + (config) => !config.toolkit?.slug || config.toolkit.slug.toLowerCase() === GMAIL_TOOLKIT, + ); + const sendConfigs = configs.filter(authConfigAllowsGmailSend); + const candidates = sendConfigs.length > 0 ? sendConfigs : configs; + + if (candidates.length === 0) { + throw new ComposioSetupError( + "No Composio Gmail auth config found. Create/connect Gmail in Composio, or rerun with --auth-config-id ac_...", + ); + } + + if (sendConfigs.length === 0) { + console.log( + chalk.yellow( + `No Gmail auth config explicitly lists ${GMAIL_SEND_TOOL}; using an existing Gmail auth config anyway.`, + ), + ); + } + + return (await chooseAuthConfig(candidates, nonInteractive, "Gmail")).id; +} + +async function chooseAccount( + accounts: ConnectedAccount[], + nonInteractive: boolean, + label = "Slack", +): Promise { + if (accounts.length === 1) return accounts[0]!; + + if (nonInteractive) { + throw new ComposioSetupError( + `Multiple active ${label} connected accounts found. Re-run with --connected-account-id.\n` + + accounts.map((account) => ` - ${account.id}`).join("\n"), + ); + } + + const clack = await import("@clack/prompts"); + const selected = await clack.select({ + message: `Select the ${label} connected account AO should use:`, + options: accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + }); + + if (clack.isCancel(selected)) { + throw new ComposioSetupError("Setup cancelled.", 0); + } + + return accounts.find((account) => account.id === selected)!; +} + +async function chooseAuthConfig( + configs: AuthConfigSummary[], + nonInteractive: boolean, + label: string, +): Promise { + if (configs.length === 1) return configs[0]!; + + if (nonInteractive) { + throw new ComposioSetupError( + `Multiple ${label} auth configs found. Re-run with --auth-config-id.\n` + + configs.map((config) => ` - ${config.id}`).join("\n"), + ); + } + + const clack = await import("@clack/prompts"); + const selected = await clack.select({ + message: `Select the ${label} auth config AO should use:`, + options: configs.map((config) => ({ + value: config.id, + label: config.id, + })), + }); + + if (clack.isCancel(selected)) { + throw new ComposioSetupError("Setup cancelled.", 0); + } + + return configs.find((config) => config.id === selected)!; +} + +function toConnectionRequest(value: unknown): ConnectionRequest { + if (!isRecord(value)) return {}; + return { + id: stringValue(value["id"]), + redirectUrl: stringValue(value["redirectUrl"]), + waitForConnection: + typeof value["waitForConnection"] === "function" + ? (value["waitForConnection"] as (timeout?: number) => Promise) + : undefined, + }; +} + +async function resolveManagedAuthConfigId( + client: ComposioSetupClient, + toolkit: string, + label: string, + name: string, + options: { + scopes?: readonly string[]; + toolsForConnectedAccountCreation?: string[]; + existingAuthConfigPredicate?: (config: AuthConfigSummary) => boolean; + forceCreate?: boolean; + } = {}, +): Promise { + if (!options.forceCreate) { + const existing = client.authConfigs?.list + ? authConfigsFromListResult(await client.authConfigs.list({ toolkit })).find( + (config) => + (!config.toolkit?.slug || config.toolkit.slug.toLowerCase() === toolkit) && + (!options.existingAuthConfigPredicate || options.existingAuthConfigPredicate(config)), + )?.id + : undefined; + if (existing) return existing; + } + + if (!client.authConfigs?.create) { + throw new ComposioSetupError( + `Composio SDK client does not expose authConfigs.create(); connect ${label} in Composio and pass --connected-account-id.`, + ); + } + + const created = await client.authConfigs.create(toolkit, { + type: "use_composio_managed_auth", + name, + ...(options.scopes ? { credentials: { scopes: [...options.scopes] } } : {}), + ...(options.toolsForConnectedAccountCreation + ? { + toolAccessConfig: { + toolsForConnectedAccountCreation: options.toolsForConnectedAccountCreation, + }, + } + : {}), + }); + const createdId = isRecord(created) ? stringValue(created["id"]) : undefined; + if (!createdId) { + throw new ComposioSetupError(`Could not create a Composio ${label} auth config.`); + } + + return createdId; +} + +async function createConnectionRequest( + client: ComposioSetupClient, + userId: string, + waitMs: number, +): Promise<{ account?: ConnectedAccount; url?: string }> { + return createManagedOAuthConnectionRequest( + client, + userId, + SLACK_TOOLKIT, + "Slack", + "Slack Auth Config", + waitMs, + ); +} + +async function createManagedOAuthConnectionRequest( + client: ComposioSetupClient, + userId: string, + toolkit: string, + label: string, + authConfigName: string, + waitMs: number, + options: { + authConfigId?: string; + scopes?: readonly string[]; + toolsForConnectedAccountCreation?: string[]; + existingAuthConfigPredicate?: (config: AuthConfigSummary) => boolean; + forceCreateAuthConfig?: boolean; + } = {}, +): Promise<{ account?: ConnectedAccount; url?: string }> { + let request: ConnectionRequest; + + if (client.connectedAccounts.link) { + const authConfigId = + options.authConfigId ?? + (await resolveManagedAuthConfigId(client, toolkit, label, authConfigName, { + scopes: options.scopes, + toolsForConnectedAccountCreation: options.toolsForConnectedAccountCreation, + existingAuthConfigPredicate: options.existingAuthConfigPredicate, + forceCreate: options.forceCreateAuthConfig, + })); + request = toConnectionRequest( + await client.connectedAccounts.link(userId, authConfigId, { allowMultiple: true }), + ); + } else if (client.toolkits?.authorize) { + request = toConnectionRequest( + await client.toolkits.authorize(userId, toolkit, options.authConfigId), + ); + } else { + throw new ComposioSetupError( + `Composio SDK client does not expose connectedAccounts.link(); connect ${label} in Composio and pass --connected-account-id.`, + ); + } + + if (request.redirectUrl) { + console.log(chalk.cyan(`Open this Composio ${label} connect URL: ${request.redirectUrl}`)); + } + + if (!request.id && !request.waitForConnection) { + return { url: request.redirectUrl }; + } + + try { + const connected = request.waitForConnection + ? await request.waitForConnection(waitMs) + : await client.connectedAccounts.waitForConnection?.(request.id!, waitMs); + const account = toConnectedAccount(connected); + return account ? { account, url: request.redirectUrl } : { url: request.redirectUrl }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(chalk.yellow(`Connection did not complete yet: ${message}`)); + return { url: request.redirectUrl }; + } +} + +function parseWaitMs(value: string | undefined): number { + if (!value) return 60_000; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new ComposioSetupError("--wait-ms must be a non-negative number."); + } + return parsed; +} + +function channelConfig(channel: string | undefined): Record { + const value = stringValue(channel); + if (!value) return {}; + if (/^[CGD][A-Z0-9]{8,}$/.test(value)) { + return { channelId: value }; + } + return { channelName: value }; +} + +function shouldUseInteractiveComposioHub( + opts: ComposioSetupOptions, + nonInteractive: boolean, +): boolean { + if (nonInteractive || opts.status) return false; + if (getDirectComposioAppChoice(opts)) return true; + return !( + stringValue(opts.apiKey) || + stringValue(opts.userId) || + stringValue(opts.channel) || + stringValue(opts.connectedAccountId) || + (stringValue(opts.waitMs) && stringValue(opts.waitMs) !== "60000") + ); +} + +function getDirectComposioAppChoice(opts: ComposioSetupOptions): ComposioAppChoice | undefined { + const choices: ComposioAppChoice[] = []; + if (opts.slack) choices.push("slack"); + if (opts.discordWebhook) choices.push("discord-webhook"); + if (opts.discordBot) choices.push("discord-bot"); + if (opts.gmail) choices.push("gmail"); + + if (choices.length > 1) { + throw new ComposioSetupError( + "Choose only one Composio app flag: --slack, --discord-webhook, --discord-bot, or --gmail.", + ); + } + return choices[0]; +} + +function shouldUseInteractiveDedicatedSetup( + opts: { nonInteractive?: boolean; status?: boolean }, + nonInteractive: boolean, +): boolean { + return !nonInteractive && !opts.status; +} + +function cancelInteractiveComposioSetup(clack: ClackPrompts): never { + clack.cancel("Setup cancelled."); + throw new ComposioSetupError("Setup cancelled.", 0); +} + +function printComposioApiKeyInstructions(): void { + console.log(""); + console.log(chalk.bold("Find your Composio API key")); + console.log(` 1. Open ${COMPOSIO_DASHBOARD_URL}`); + console.log(" 2. Open your project settings or developer settings."); + console.log(" 3. Create or copy an API key that can execute tools."); + console.log(""); +} + +function printComposioSlackAccountInfo(): void { + console.log( + chalk.dim( + "AO uses a Composio Slack connected account to execute SLACK_SEND_MESSAGE. The userId groups connected accounts inside your Composio project.", + ), + ); +} + +function printComposioSlackChannelInfo(): void { + console.log( + chalk.dim( + "Slack channel is optional for Composio. If set, AO passes it to SLACK_SEND_MESSAGE; use the channel name without # or a Slack channel id.", + ), + ); +} + +function printComposioSlackReview(resolved: ResolvedComposioSetup, apiKeySource: string): void { + console.log(""); + console.log(chalk.bold("Review Composio Slack setup")); + console.log(" app: Slack"); + console.log(` notifier: ${resolved.targetName ?? COMPOSIO_NOTIFIER}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + console.log(` channel: ${resolved.channel ?? "not set"}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function redactDiscordWebhookUrl(webhookUrl: string | undefined): string { + if (!webhookUrl) return "not configured"; + try { + const parsed = new URL(webhookUrl); + const segments = parsed.pathname.split("/").filter(Boolean); + const webhookIndex = segments.findIndex((segment) => segment === "webhooks"); + if (webhookIndex >= 0 && segments[webhookIndex + 1]) { + return `${parsed.origin}/api/webhooks/${segments[webhookIndex + 1]}/...`; + } + } catch { + // Fall through to generic redaction. + } + return "configured"; +} + +function printComposioDiscordWebhookInfo(): void { + console.log( + chalk.dim( + "AO uses Composio's discordbot toolkit with DISCORDBOT_EXECUTE_WEBHOOK. Webhook mode stores the Discord webhook token as a Composio bearer connected account; no Discord bot invite is required.", + ), + ); +} + +function printDiscordWebhookInstructions(): void { + console.log(""); + console.log(chalk.bold("Create a Discord webhook URL")); + console.log(` 1. Open ${DISCORD_APP_URL}`); + console.log(" 2. Open the target server and channel."); + console.log(" 3. Open Edit Channel > Integrations > Webhooks."); + console.log(" 4. Create a webhook and copy its URL."); + console.log(" 5. Paste the URL here."); + console.log(chalk.dim(`Discord help: ${DISCORD_WEBHOOK_DOCS_URL}`)); + console.log(""); +} + +function printComposioDiscordWebhookReview( + resolved: ResolvedDiscordSetup, + apiKeySource: string, +): void { + console.log(""); + console.log(chalk.bold("Review Composio Discord webhook setup")); + console.log(" app: Discord webhook"); + console.log(` notifier: ${resolved.targetName}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` webhookUrl: ${redactDiscordWebhookUrl(resolved.webhookUrl)}`); + console.log( + ` connectedAccountId: ${resolved.connectedAccountId ?? "will be created from webhook URL"}`, + ); + console.log(` toolVersion: ${DISCORD_TOOL_VERSION}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function printComposioDiscordBotInfo(): void { + console.log( + chalk.dim( + "AO uses Composio's discordbot toolkit with DISCORDBOT_CREATE_MESSAGE. Bot mode requires a Discord channel id and a Composio Discord bot connected account.", + ), + ); +} + +function printDiscordBotInstructions(): void { + console.log(""); + console.log(chalk.bold("Create and invite a Discord bot")); + console.log(` 1. Open ${DISCORD_DEVELOPER_PORTAL_URL}`); + console.log(" 2. Create or select an application, then open Bot."); + console.log(" 3. Create/reset the bot token and keep it available for this setup."); + console.log(" 4. Open OAuth2 > URL Generator."); + console.log(" 5. Select the bot scope and grant View Channel + Send Messages."); + console.log(" 6. Open the generated URL and invite the bot to the target server."); + console.log(" 7. In Discord, enable Developer Mode and copy the target channel ID."); + console.log(""); +} + +function printDiscordChannelIdInstructions(): void { + console.log(""); + console.log(chalk.bold("Find a Discord channel ID")); + console.log(" 1. Open Discord User Settings > Advanced."); + console.log(" 2. Enable Developer Mode."); + console.log(" 3. Right-click the target channel."); + console.log(" 4. Click Copy Channel ID and paste it here."); + console.log(""); +} + +function printComposioDiscordBotReview(resolved: ResolvedDiscordSetup, apiKeySource: string): void { + console.log(""); + console.log(chalk.bold("Review Composio Discord bot setup")); + console.log(" app: Discord bot"); + console.log(` notifier: ${resolved.targetName}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` channelId: ${resolved.channelId ?? "not configured"}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + console.log(` toolVersion: ${DISCORD_TOOL_VERSION}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function printComposioGmailInfo(): void { + console.log( + chalk.dim( + "AO uses Composio's Gmail toolkit with GMAIL_SEND_EMAIL. Gmail mode requires a recipient email and a Gmail connected account with send/profile access.", + ), + ); +} + +function printComposioGmailConnectInfo(): void { + console.log(""); + console.log(chalk.bold("Connect Gmail in Composio")); + console.log(` 1. Open ${COMPOSIO_DASHBOARD_URL}`); + console.log(" 2. Make sure your project has a Gmail auth config with send access."); + console.log(" 3. AO can create a Composio connect link from that existing auth config."); + console.log(" 4. Complete the Google OAuth flow, then return here."); + console.log( + chalk.dim( + "AO does not create Gmail OAuth/auth configs because Google may block unverified or invalid OAuth apps.", + ), + ); + console.log(""); +} + +function printComposioGmailReview(resolved: ResolvedMailSetup, apiKeySource: string): void { + console.log(""); + console.log(chalk.bold("Review Composio Gmail setup")); + console.log(" app: Gmail"); + console.log(` notifier: ${resolved.targetName ?? COMPOSIO_NOTIFIER}`); + console.log(` api key: configured from ${apiKeySource}`); + console.log(` userId: ${resolved.userId}`); + console.log(` emailTo: ${resolved.emailTo ?? "not configured"}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + console.log(` toolVersion: ${GMAIL_TOOL_VERSION}`); + console.log(` routing: ${routingReviewLabel(resolved.routingPreset)}`); + console.log(""); +} + +function printComposioAppRequirements(choice: ComposioAppChoice): void { + console.log(""); + if (choice === "discord-webhook") { + console.log(chalk.bold("Composio Discord webhook setup")); + console.log(" Required: Composio API key, userId, Discord webhook URL."); + console.log(" AO creates/stores a Composio connected account from the webhook token."); + console.log(" No Discord bot invite is required for webhook mode."); + console.log(" Current command: ao setup composio-discord --webhook-url "); + } else if (choice === "discord-bot") { + console.log(chalk.bold("Composio Discord bot setup")); + console.log(" Required: Composio API key, userId, Discord channel id."); + console.log(" Also required once: bot token, unless you already have connectedAccountId."); + console.log(" Current command: ao setup composio-discord-bot --channel-id "); + } else if (choice === "gmail") { + console.log(chalk.bold("Composio Gmail setup")); + console.log(" Required: Composio API key, userId, recipient email, Gmail connectedAccountId."); + console.log(" Gmail OAuth/auth config must be usable in Composio with send/profile access."); + console.log(" Current command: ao setup composio-mail --email-to "); + } + console.log( + chalk.dim("This interactive hub currently implements Slack, Discord webhook/bot, and Gmail."), + ); + console.log(""); +} + +async function promptApiKeyInput(clack: ClackPrompts): Promise { + const apiKeyInput = await clack.password({ + message: "Composio API key:", + validate: (value) => { + if (!String(value ?? "").trim()) return "Composio API key is required."; + }, + }); + + if (clack.isCancel(apiKeyInput)) { + cancelInteractiveComposioSetup(clack); + } + + return { + apiKey: String(apiKeyInput).trim(), + shouldWriteApiKey: true, + sourceLabel: "prompt", + }; +} + +async function promptInteractiveComposioApiKey( + clack: ClackPrompts, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const existingKey = resolveApiKeyCandidate(opts, existing); + + while (true) { + const choice = existingKey + ? await clack.select({ + message: `Composio API key is already available from ${existingKey.sourceLabel}.`, + options: [ + { + value: "use-existing", + label: "Use existing API key", + hint: "Keep the configured key", + }, + { + value: "enter-new", + label: "Enter a new API key", + hint: "Store it in this config", + }, + { + value: "show-steps", + label: "Show where to find it", + hint: "Print Composio dashboard steps", + }, + { value: "back", label: "Back", hint: "Return to app choices" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }) + : await clack.select({ + message: "Composio API key is required to list and create connected accounts.", + options: [ + { + value: "enter-new", + label: "Enter API key", + hint: "Store it in this config", + }, + { + value: "show-steps", + label: "Show where to find it", + hint: "Print Composio dashboard steps", + }, + { value: "back", label: "Back", hint: "Return to app choices" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingKey) return existingKey; + if (choice === "show-steps") { + printComposioApiKeyInstructions(); + continue; + } + if (choice === "enter-new") { + return promptApiKeyInput(clack); + } + } +} + +async function promptInteractiveComposioUserId( + clack: ClackPrompts, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const currentUserId = resolveUserId(opts, existing); + console.log( + chalk.dim( + `userId is the Composio user namespace AO uses for tool execution and connected-account lookup. For AO-managed setups, ${DEFAULT_COMPOSIO_USER_ID} is the recommended default.`, + ), + ); + + while (true) { + const choice = await clack.select({ + message: `Composio userId: ${currentUserId}`, + options: [ + { value: "use-current", label: `Use ${currentUserId}`, hint: "Recommended" }, + { value: "change", label: "Change userId", hint: "Use a different Composio user id" }, + { value: "back", label: "Back", hint: "Return to API key" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-current") return currentUserId; + if (choice === "change") { + const nextUserId = await clack.text({ + message: "Composio userId:", + initialValue: currentUserId, + validate: (value) => { + if (!String(value ?? "").trim()) return "Composio userId is required."; + }, + }); + if (clack.isCancel(nextUserId)) { + cancelInteractiveComposioSetup(clack); + } + return String(nextUserId).trim(); + } + } +} + +async function promptManualSlackConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Slack connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyConnectedAccount(client, userId, String(accountId).trim()); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseSlackConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow("No active Slack connected accounts were found for this Composio userId."), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Slack connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Slack account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptAfterSlackConnectLink( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + existingConnectedAccountId: string | undefined, +): Promise { + console.log( + chalk.yellow( + "Slack connection did not complete yet. Open the connect URL above and finish the Composio flow.", + ), + ); + + while (true) { + const next = await clack.select({ + message: "After opening the Composio Slack connect link, what do you want to do?", + options: [ + { + value: "check-active", + label: "I completed the connection", + hint: "Check Composio for active Slack accounts", + }, + { + value: "retry-link", + label: "Generate link again", + hint: "Create a fresh Composio Slack connect URL", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { + value: "back", + label: "Back", + hint: "Return to Slack account options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "retry-link") return "retry-link"; + + if (next === "enter-id") { + const accountId = await promptManualSlackConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (next === "check-active") { + const account = await promptChooseSlackConnectedAccount( + clack, + await listActiveSlackAccounts(client, userId), + ); + if (account !== "back") return account.id; + } + } +} + +async function promptInteractiveSlackAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const existingConnectedAccountId = + stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"]); + printComposioSlackAccountInfo(); + + while (true) { + const options = [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "choose-active", + label: "Choose active Slack account", + hint: "List accounts already connected in Composio", + }, + { + value: "create-link", + label: "Generate Slack connect link", + hint: "Open Composio OAuth link and wait for completion", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ]; + const choice = await clack.select({ + message: "How do you want to choose the Slack connected account?", + options, + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyConnectedAccount(client, userId, existingConnectedAccountId); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + continue; + } + } + + if (choice === "choose-active") { + const account = await promptChooseSlackConnectedAccount( + clack, + await listActiveSlackAccounts(client, userId), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "create-link") { + while (true) { + const connection = await createConnectionRequest(client, userId, parseWaitMs(opts.waitMs)); + if (connection.account) return connection.account.id; + const next = await promptAfterSlackConnectLink( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (next === "retry-link") continue; + if (next !== "back") return next; + break; + } + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualSlackConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + } + } +} + +async function promptInteractiveSlackChannel( + clack: ClackPrompts, + opts: ComposioSetupOptions, + existing: Record, +): Promise { + const existingChannel = + stringValue(opts.channel) ?? + stringValue(existing["channelName"]) ?? + stringValue(existing["channelId"]); + printComposioSlackChannelInfo(); + + while (true) { + const choice = await clack.select({ + message: `Slack channel: ${existingChannel ?? "not set"}`, + options: [ + { + value: "use-current", + label: existingChannel ? `Use ${existingChannel}` : "Leave unset", + hint: existingChannel ? "Keep current Slack target override" : "Do not pass channel", + }, + { + value: "change", + label: "Set channel", + hint: "Channel name without #, or a Slack channel id", + }, + ...(existingChannel + ? [{ value: "clear", label: "Clear channel", hint: "Do not pass channel" }] + : []), + { value: "back", label: "Back", hint: "Return to Slack account" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-current") return existingChannel; + if (choice === "clear") return undefined; + if (choice === "change") { + const channel = await clack.text({ + message: "Slack channel name or id:", + placeholder: "iamasx", + initialValue: existingChannel, + }); + if (clack.isCancel(channel)) { + cancelInteractiveComposioSetup(clack); + } + return stringValue(channel); + } + } +} + +async function promptInteractiveComposioSlackReview( + clack: ClackPrompts, + resolved: ResolvedComposioSetup, + apiKeySource: string, +): Promise<"write" | "channel" | "account" | "routing" | "app" | "cancel"> { + printComposioSlackReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Slack config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "channel", label: "Change channel", hint: "Return to channel step" }, + { value: "account", label: "Change Slack account", hint: "Return to account step" }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "channel" | "account" | "routing" | "app" | "cancel"; +} + +async function promptDiscordWebhookUrlInput( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const webhookUrlInput = await clack.text({ + message: "Discord webhook URL:", + placeholder: "https://discord.com/api/webhooks/...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "Discord webhook URL is required."; + try { + parseDiscordWebhookUrl(String(value).trim()); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(webhookUrlInput)) { + cancelInteractiveComposioSetup(clack); + } + + return String(webhookUrlInput).trim(); +} + +async function promptAfterDiscordWebhookInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printDiscordWebhookInstructions(); + + while (true) { + const next = await clack.select({ + message: "After creating the Discord webhook, what do you want to do?", + options: [ + { value: "enter-url", label: "Paste webhook URL", hint: "Continue setup" }, + { + value: "show-steps", + label: "Show steps again", + hint: "Reprint the Discord app URL and steps", + }, + { value: "back", label: "Back", hint: "Return to webhook URL options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "show-steps") { + printDiscordWebhookInstructions(); + continue; + } + if (next === "enter-url") { + return promptDiscordWebhookUrlInput(clack, initialValue); + } + } +} + +async function promptInteractiveDiscordWebhookUrl( + clack: ClackPrompts, + existingWebhookUrl: string | undefined, +): Promise { + printComposioDiscordWebhookInfo(); + + while (true) { + const choice = await clack.select({ + message: `Discord webhook URL: ${redactDiscordWebhookUrl(existingWebhookUrl)}`, + options: [ + ...(existingWebhookUrl + ? [ + { + value: "use-existing", + label: "Use existing webhook URL", + hint: redactDiscordWebhookUrl(existingWebhookUrl), + }, + ] + : []), + { + value: "enter-url", + label: "Paste webhook URL", + hint: "Use a Discord incoming webhook URL", + }, + { + value: "show-steps", + label: "Show me how to create one", + hint: "Print Discord webhook creation steps", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingWebhookUrl) return existingWebhookUrl; + if (choice === "enter-url") { + return promptDiscordWebhookUrlInput(clack, existingWebhookUrl); + } + if (choice === "show-steps") { + const result = await promptAfterDiscordWebhookInstructions(clack, existingWebhookUrl); + if (result !== "back") return result; + } + } +} + +async function promptInteractiveComposioDiscordWebhookReview( + clack: ClackPrompts, + resolved: ResolvedDiscordSetup, + apiKeySource: string, +): Promise<"write" | "webhook" | "account" | "routing" | "app" | "cancel"> { + printComposioDiscordWebhookReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Discord webhook config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "webhook", label: "Change webhook URL", hint: "Return to webhook URL step" }, + { + value: "account", + label: "Change connected account", + hint: "Create, choose, or enter a Composio connected account", + }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "webhook" | "account" | "routing" | "app" | "cancel"; +} + +async function promptManualDiscordWebhookConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Discord webhook connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + String(accountId).trim(), + DISCORD_TOOLKIT, + "Discord webhook", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseDiscordWebhookConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow( + "No active Discord webhook connected accounts were found for this Composio userId.", + ), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Discord webhook connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Discord webhook account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptInteractiveDiscordWebhookAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + webhookUrl: string, + existingConnectedAccountId: string | undefined, +): Promise { + while (true) { + const choice = await clack.select({ + message: "How do you want to configure the Composio Discord webhook connected account?", + options: [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "create-account", + label: "Create from webhook URL", + hint: "Store the webhook token in Composio for this userId", + }, + { + value: "choose-active", + label: "Choose active Discord account", + hint: "List discordbot accounts already connected in Composio", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { value: "back", label: "Back", hint: "Return to webhook URL" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + existingConnectedAccountId, + DISCORD_TOOLKIT, + "Discord webhook", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + continue; + } + } + + if (choice === "create-account") { + return resolveDiscordWebhookConnectedAccountId(client, userId, webhookUrl); + } + + if (choice === "choose-active") { + const account = await promptChooseDiscordWebhookConnectedAccount( + clack, + await listActiveToolkitAccounts(client, userId, DISCORD_TOOLKIT), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualDiscordWebhookConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + } + } +} + +function validateDiscordChannelIdInput(value: string): string | undefined { + if (!value.trim()) return "Discord channel id is required."; + if (!/^\d{8,}$/.test(value.trim())) return "Discord channel id must be numeric."; +} + +async function promptDiscordBotChannelIdInput( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const channelIdInput = await clack.text({ + message: "Discord channel ID:", + placeholder: "1234567890", + initialValue, + validate: (value) => validateDiscordChannelIdInput(String(value ?? "")), + }); + + if (clack.isCancel(channelIdInput)) { + cancelInteractiveComposioSetup(clack); + } + + return String(channelIdInput).trim(); +} + +async function promptAfterDiscordChannelIdInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printDiscordChannelIdInstructions(); + + while (true) { + const next = await clack.select({ + message: "After copying the Discord channel ID, what do you want to do?", + options: [ + { value: "enter-id", label: "Paste channel ID", hint: "Continue setup" }, + { + value: "show-steps", + label: "Show steps again", + hint: "Reprint the Developer Mode steps", + }, + { value: "back", label: "Back", hint: "Return to channel options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "show-steps") { + printDiscordChannelIdInstructions(); + continue; + } + if (next === "enter-id") { + return promptDiscordBotChannelIdInput(clack, initialValue); + } + } +} + +async function promptInteractiveDiscordBotChannel( + clack: ClackPrompts, + existingChannelId: string | undefined, +): Promise { + printComposioDiscordBotInfo(); + + while (true) { + const choice = await clack.select({ + message: `Discord channel ID: ${existingChannelId ?? "not configured"}`, + options: [ + ...(existingChannelId + ? [ + { + value: "use-existing", + label: "Use existing channel ID", + hint: existingChannelId, + }, + ] + : []), + { value: "enter-id", label: "Paste channel ID", hint: "Use a Discord channel id" }, + { + value: "show-steps", + label: "Show me how to find it", + hint: "Print Developer Mode channel-id steps", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingChannelId) return existingChannelId; + if (choice === "enter-id") return promptDiscordBotChannelIdInput(clack, existingChannelId); + if (choice === "show-steps") { + const result = await promptAfterDiscordChannelIdInstructions(clack, existingChannelId); + if (result !== "back") return result; + } + } +} + +async function promptManualDiscordBotConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Discord bot connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + String(accountId).trim(), + DISCORD_TOOLKIT, + "Discord Bot", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseDiscordBotConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow("No active Discord bot connected accounts were found for this Composio userId."), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Discord bot connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Discord bot account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptDiscordBotTokenInput( + clack: ClackPrompts, + optionToken: string | undefined, +): Promise { + const envToken = stringValue(process.env.DISCORD_BOT_TOKEN); + if (optionToken || envToken) { + const choice = await clack.select({ + message: "Discord bot token:", + options: [ + ...(optionToken + ? [ + { + value: "use-option", + label: "Use provided bot token", + hint: "Used once; not written to config", + }, + ] + : []), + ...(envToken + ? [ + { + value: "use-env", + label: "Use DISCORD_BOT_TOKEN", + hint: "Use the current environment", + }, + ] + : []), + { value: "paste", label: "Paste bot token", hint: "Used once; not written to config" }, + { value: "back", label: "Back", hint: "Return to account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-option" && optionToken) return optionToken; + if (choice === "use-env" && envToken) return envToken; + } + + printDiscordBotInstructions(); + const token = await clack.password({ + message: "Discord bot token:", + validate: (value) => { + if (!String(value ?? "").trim()) return "Discord bot token is required."; + }, + }); + + if (clack.isCancel(token)) { + cancelInteractiveComposioSetup(clack); + } + + return String(token).trim(); +} + +async function promptInteractiveDiscordBotAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + channelId: string, + existingConnectedAccountId: string | undefined, + optionToken: string | undefined, +): Promise { + while (true) { + const choice = await clack.select({ + message: "How do you want to configure the Composio Discord bot account?", + options: [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "choose-active", + label: "Choose active Discord bot account", + hint: "List active discordbot accounts for this userId", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { + value: "create-account", + label: "Create from bot token", + hint: "Validate channel access and store a Composio connected account", + }, + { value: "back", label: "Back", hint: "Return to channel ID" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + existingConnectedAccountId, + DISCORD_TOOLKIT, + "Discord Bot", + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + continue; + } + + if (choice === "choose-active") { + const account = await promptChooseDiscordBotConnectedAccount( + clack, + await listActiveToolkitAccounts(client, userId, DISCORD_TOOLKIT), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualDiscordBotConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (choice === "create-account") { + const token = await promptDiscordBotTokenInput(clack, optionToken); + if (token === "back") continue; + try { + await validateDiscordBotChannelAccess(token, channelId); + return await createDiscordBearerConnectedAccount( + client, + userId, + token, + "Discord Bot Auth Config", + ); + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + } + } +} + +async function promptInteractiveComposioDiscordBotReview( + clack: ClackPrompts, + resolved: ResolvedDiscordSetup, + apiKeySource: string, +): Promise<"write" | "channel" | "account" | "routing" | "app" | "cancel"> { + printComposioDiscordBotReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Discord bot config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "channel", label: "Change channel ID", hint: "Return to channel step" }, + { value: "account", label: "Change bot account", hint: "Return to account step" }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "channel" | "account" | "routing" | "app" | "cancel"; +} + +function validateEmailInput(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return "Recipient email is required."; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return "Enter a valid email address."; +} + +async function promptGmailEmailInput( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const email = await clack.text({ + message: "Recipient email:", + placeholder: "alerts@example.com", + initialValue, + validate: (value) => validateEmailInput(String(value ?? "")), + }); + + if (clack.isCancel(email)) { + cancelInteractiveComposioSetup(clack); + } + + return String(email).trim(); +} + +async function promptInteractiveGmailEmail( + clack: ClackPrompts, + existingEmailTo: string | undefined, +): Promise { + printComposioGmailInfo(); + + while (true) { + const choice = await clack.select({ + message: `Gmail recipient email: ${existingEmailTo ?? "not configured"}`, + options: [ + ...(existingEmailTo + ? [ + { + value: "use-existing", + label: "Use existing recipient", + hint: existingEmailTo, + }, + ] + : []), + { + value: "enter-email", + label: "Enter recipient email", + hint: "AO sends notification emails to this address", + }, + { value: "back", label: "Back", hint: "Return to userId" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingEmailTo) return existingEmailTo; + if (choice === "enter-email") return promptGmailEmailInput(clack, existingEmailTo); + } +} + +async function verifyUsableGmailConnectedAccount( + client: ComposioSetupClient, + userId: string, + connectedAccountId: string, +): Promise { + const account = await withConnectedAccountDetails( + client, + await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + GMAIL_TOOLKIT, + "Gmail", + () => listActiveGmailAccounts(client, userId), + ), + ); + if (!(await accountCanSendGmail(client, account))) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is missing Gmail send/profile access. Reconnect Gmail in Composio with send access, or use a different Gmail connected account.`, + ); + } + return account; +} + +async function promptManualGmailConnectedAccountId( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + initialValue: string | undefined, +): Promise { + const accountId = await clack.text({ + message: "Composio Gmail connectedAccountId:", + placeholder: "ca_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "connectedAccountId is required."; + }, + }); + + if (clack.isCancel(accountId)) { + cancelInteractiveComposioSetup(clack); + } + + try { + const account = await verifyUsableGmailConnectedAccount( + client, + userId, + String(accountId).trim(), + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + return "back"; + } +} + +async function promptChooseGmailConnectedAccount( + clack: ClackPrompts, + accounts: ConnectedAccount[], +): Promise { + if (accounts.length === 0) { + console.log( + chalk.yellow( + "No active Gmail connected accounts with send/profile access were found for this Composio userId.", + ), + ); + return "back"; + } + + const selected = await clack.select({ + message: "Select the Gmail connected account AO should use:", + options: [ + ...accounts.map((account) => ({ + value: account.id, + label: account.alias ? `${account.alias} (${account.id})` : account.id, + })), + { value: "back", label: "Back", hint: "Return to Gmail account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return accounts.find((account) => account.id === selected) ?? "back"; +} + +async function promptManualGmailAuthConfigId( + clack: ClackPrompts, + client: ComposioSetupClient, + initialValue: string | undefined, +): Promise { + const authConfigId = await clack.text({ + message: "Composio Gmail authConfigId:", + placeholder: "ac_...", + initialValue, + validate: (value) => { + if (!String(value ?? "").trim()) return "authConfigId is required."; + }, + }); + + if (clack.isCancel(authConfigId)) { + cancelInteractiveComposioSetup(clack); + } + + const id = String(authConfigId).trim(); + const config = await getAuthConfig(client, id); + if (config?.toolkit?.slug && config.toolkit.slug.toLowerCase() !== GMAIL_TOOLKIT) { + console.log(chalk.yellow(`Auth config ${id} is not a Gmail config.`)); + return "back"; + } + if (config && !authConfigAllowsGmailSend(config)) { + console.log( + chalk.yellow( + `Auth config ${id} does not explicitly list ${GMAIL_SEND_TOOL}; AO will create the connect link anyway.`, + ), + ); + } + return id; +} + +async function listGmailAuthConfigs(client: ComposioSetupClient): Promise { + if (!client.authConfigs?.list) { + throw new ComposioSetupError( + "Composio SDK client does not expose authConfigs.list(); enter a Gmail authConfigId manually.", + ); + } + return authConfigsFromListResult( + await client.authConfigs.list({ toolkit: GMAIL_TOOLKIT }), + ).filter( + (config) => !config.toolkit?.slug || config.toolkit.slug.toLowerCase() === GMAIL_TOOLKIT, + ); +} + +async function promptChooseGmailAuthConfig( + clack: ClackPrompts, + configs: AuthConfigSummary[], +): Promise { + if (configs.length === 0) { + console.log( + chalk.yellow( + "No Composio Gmail auth configs were found. Create one in Composio or enter authConfigId manually.", + ), + ); + return "back"; + } + + const sendConfigs = configs.filter(authConfigAllowsGmailSend); + const candidates = sendConfigs.length > 0 ? sendConfigs : configs; + if (sendConfigs.length === 0) { + console.log( + chalk.yellow( + `No Gmail auth config explicitly lists ${GMAIL_SEND_TOOL}; showing existing Gmail auth configs anyway.`, + ), + ); + } + + const selected = await clack.select({ + message: "Select the Gmail auth config for the Composio connect link:", + options: [ + ...candidates.map((config) => ({ + value: config.id, + label: config.id, + hint: authConfigAllowsGmailSend(config) ? GMAIL_SEND_TOOL : "Gmail auth config", + })), + { value: "back", label: "Back", hint: "Return to Gmail account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(selected) || selected === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (selected === "back") return "back"; + return candidates.find((config) => config.id === selected) ?? "back"; +} + +async function promptInteractiveGmailAuthConfig( + clack: ClackPrompts, + client: ComposioSetupClient, + existingAuthConfigId: string | undefined, +): Promise { + printComposioGmailConnectInfo(); + + while (true) { + const choice = await clack.select({ + message: "How should AO choose the Gmail auth config for the connect link?", + options: [ + ...(existingAuthConfigId + ? [ + { + value: "use-existing", + label: "Use existing authConfigId", + hint: existingAuthConfigId, + }, + ] + : []), + { + value: "choose-existing", + label: "Choose existing Gmail auth config", + hint: "List Gmail auth configs in Composio", + }, + { + value: "enter-id", + label: "Enter authConfigId", + hint: "Use an existing ac_... value", + }, + { value: "back", label: "Back", hint: "Return to Gmail account options" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + if (choice === "use-existing" && existingAuthConfigId) return existingAuthConfigId; + + if (choice === "enter-id") { + const id = await promptManualGmailAuthConfigId(clack, client, existingAuthConfigId); + if (id !== "back") return id; + continue; + } + + if (choice === "choose-existing") { + try { + const config = await promptChooseGmailAuthConfig(clack, await listGmailAuthConfigs(client)); + if (config !== "back") return config.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + } + } +} + +async function promptAfterGmailConnectLink( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + existingConnectedAccountId: string | undefined, +): Promise { + console.log( + chalk.yellow( + "Gmail connection did not complete yet. Open the connect URL above and finish the Composio flow.", + ), + ); + + while (true) { + const next = await clack.select({ + message: "After opening the Composio Gmail connect link, what do you want to do?", + options: [ + { + value: "check-active", + label: "I completed the connection", + hint: "Check Composio for usable Gmail accounts", + }, + { + value: "retry-link", + label: "Generate link again", + hint: "Use the same Gmail auth config", + }, + { + value: "change-auth-config", + label: "Change authConfigId", + hint: "Use a different Gmail auth config", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { + value: "back", + label: "Back", + hint: "Return to Gmail account options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (next === "back") return "back"; + if (next === "retry-link") return "retry-link"; + if (next === "change-auth-config") return "change-auth-config"; + + if (next === "enter-id") { + const accountId = await promptManualGmailConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (next === "check-active") { + const account = await promptChooseGmailConnectedAccount( + clack, + await listUsableGmailAccounts(client, userId), + ); + if (account !== "back") return account.id; + } + } +} + +async function promptInteractiveGmailAccount( + clack: ClackPrompts, + client: ComposioSetupClient, + userId: string, + opts: ComposioSetupOptions, + existingConnectedAccountId: string | undefined, + existingAuthConfigId: string | undefined, +): Promise { + let authConfigId = existingAuthConfigId; + + while (true) { + const choice = await clack.select({ + message: "How do you want to choose the Gmail connected account?", + options: [ + ...(existingConnectedAccountId + ? [ + { + value: "use-existing", + label: "Use existing connected account", + hint: existingConnectedAccountId, + }, + ] + : []), + { + value: "choose-active", + label: "Choose active Gmail account", + hint: "List accounts already connected in Composio", + }, + { + value: "create-link", + label: "Generate Gmail connect link", + hint: "Use an existing Composio Gmail auth config", + }, + { + value: "enter-id", + label: "Enter connectedAccountId", + hint: "Use an existing ca_... value", + }, + { value: "back", label: "Back", hint: "Return to recipient email" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + if (choice === "back") return "back"; + + if (choice === "use-existing" && existingConnectedAccountId) { + try { + const account = await verifyUsableGmailConnectedAccount( + client, + userId, + existingConnectedAccountId, + ); + return account.id; + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + continue; + } + + if (choice === "choose-active") { + const account = await promptChooseGmailConnectedAccount( + clack, + await listUsableGmailAccounts(client, userId), + ); + if (account !== "back") return account.id; + continue; + } + + if (choice === "enter-id") { + const accountId = await promptManualGmailConnectedAccountId( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (accountId !== "back") return accountId; + continue; + } + + if (choice === "create-link") { + while (true) { + if (!authConfigId) { + const selectedAuthConfigId = await promptInteractiveGmailAuthConfig( + clack, + client, + authConfigId, + ); + if (selectedAuthConfigId === "back") break; + authConfigId = selectedAuthConfigId; + } + + const connection = await createManagedOAuthConnectionRequest( + client, + userId, + GMAIL_TOOLKIT, + "Gmail", + "Gmail Auth Config", + parseWaitMs(opts.waitMs), + { authConfigId }, + ); + + if (connection.account) { + try { + const account = await withConnectedAccountDetails(client, connection.account); + if (await accountCanSendGmail(client, account)) return account.id; + console.log( + chalk.yellow( + `Connected Gmail account ${account.id} is missing Gmail send/profile access.`, + ), + ); + } catch (error) { + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + } + } + + const next = await promptAfterGmailConnectLink( + clack, + client, + userId, + existingConnectedAccountId, + ); + if (next === "retry-link") continue; + if (next === "change-auth-config") { + authConfigId = undefined; + continue; + } + if (next !== "back") return next; + break; + } + } + } +} + +async function promptInteractiveComposioGmailReview( + clack: ClackPrompts, + resolved: ResolvedMailSetup, + apiKeySource: string, +): Promise<"write" | "email" | "account" | "routing" | "app" | "cancel"> { + printComposioGmailReview(resolved, apiKeySource); + const choice = await clack.select({ + message: "Write this Composio Gmail config?", + options: [ + { value: "write", label: "Write config", hint: "Update agent-orchestrator.yaml" }, + { value: "email", label: "Change recipient email", hint: "Return to email step" }, + { value: "account", label: "Change Gmail account", hint: "Return to account step" }, + { value: "routing", label: "Change routing", hint: "Choose notification priorities" }, + { value: "app", label: "Back to app choices", hint: "Choose another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return choice as "write" | "email" | "account" | "routing" | "app" | "cancel"; +} + +async function confirmComposioSlackConflict( + clack: ClackPrompts, + targetName: string, + existingPlugin: string | undefined, + force: boolean | undefined, +): Promise { + if (!existingPlugin || existingPlugin === "composio" || force) return true; + const replace = await clack.confirm({ + message: `notifiers.${targetName} already uses plugin "${existingPlugin}". Replace it with Composio Slack?`, + initialValue: false, + }); + + if (clack.isCancel(replace)) { + cancelInteractiveComposioSetup(clack); + } + if (!replace) { + console.log(chalk.dim(`Keeping existing notifiers.${targetName} config.`)); + return false; + } + return true; +} + +async function confirmComposioDiscordWebhookConflict( + clack: ClackPrompts, + targetName: string, + existingPlugin: string | undefined, + force: boolean | undefined, + label = "Composio Discord webhook", +): Promise { + if (!existingPlugin || existingPlugin === "composio" || force) return true; + const replace = await clack.confirm({ + message: `notifiers.${targetName} already uses plugin "${existingPlugin}". Replace it with ${label}?`, + initialValue: false, + }); + + if (clack.isCancel(replace)) { + cancelInteractiveComposioSetup(clack); + } + if (!replace) { + console.log(chalk.dim(`Keeping existing notifiers.${targetName} config.`)); + return false; + } + return true; +} + +async function runInteractiveComposioSlackSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const canReplace = await confirmComposioSlackConflict( + clack, + targetName, + existingPlugin, + opts.force, + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "account" | "channel" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let client: ComposioSetupClient | undefined; + let connectedAccountId: string | undefined; + let channel: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + client = await loadComposioClient(apiKey.apiKey); + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "account"; + continue; + } + + if (step === "account") { + if (!client || !userId) { + step = "api-key"; + continue; + } + const result = await promptInteractiveSlackAccount(clack, client, userId, opts, existing); + if (result === "back") { + step = "user-id"; + continue; + } + connectedAccountId = result; + step = "channel"; + continue; + } + + if (step === "channel") { + const result = await promptInteractiveSlackChannel(clack, opts, existing); + if (result === "back") { + step = "account"; + continue; + } + channel = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, targetName, "Composio Slack", () => + cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "channel"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !connectedAccountId) { + step = "api-key"; + continue; + } + + const resolved: ResolvedComposioSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + targetName, + channel, + connectedAccountId, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioSlackReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "channel") { + step = "channel"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Slack connected account: ${connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template ci-failing`)); + clack.outro("Composio Slack setup complete."); + return "done"; + } +} + +async function runInteractiveComposioDiscordWebhookSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const existingWebhookUrl = + stringValue(opts.webhookUrl) ?? + stringValue(existing["webhookUrl"]) ?? + stringValue(process.env.DISCORD_WEBHOOK_URL); + const explicitConnectedAccountId = stringValue(opts.connectedAccountId); + const existingConnectedAccountId = stringValue(existing["connectedAccountId"]); + const canReplace = await confirmComposioDiscordWebhookConflict( + clack, + targetName, + existingPlugin, + opts.force, + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "webhook" | "account" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let webhookUrl: string | undefined; + let connectedAccountId: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + let setupClient: ComposioSetupClient | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "webhook"; + continue; + } + + if (step === "webhook") { + const result = await promptInteractiveDiscordWebhookUrl( + clack, + webhookUrl ?? existingWebhookUrl, + ); + if (result === "back") { + step = "user-id"; + continue; + } + webhookUrl = result; + connectedAccountId = explicitConnectedAccountId; + step = "account"; + continue; + } + + if (step === "account") { + if (!apiKey || !userId || !webhookUrl) { + step = "api-key"; + continue; + } + + setupClient ??= await loadComposioClient(apiKey.apiKey); + const result = await promptInteractiveDiscordWebhookAccount( + clack, + setupClient, + userId, + webhookUrl, + connectedAccountId ?? explicitConnectedAccountId ?? existingConnectedAccountId, + ); + if (result === "back") { + step = "webhook"; + continue; + } + connectedAccountId = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset( + clack, + rawConfig, + targetName, + "Composio Discord webhook", + () => cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "webhook"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !webhookUrl) { + step = "api-key"; + continue; + } + if (!connectedAccountId) { + step = "account"; + continue; + } + + const resolved: ResolvedDiscordSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + mode: "webhook", + targetName, + webhookUrl, + connectedAccountId, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioDiscordWebhookReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "webhook") { + step = "webhook"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green("✓ Discord webhook configured through Composio")); + console.log(chalk.green(`✓ Discord webhook connected account: ${resolved.connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template basic`)); + clack.outro("Composio Discord webhook setup complete."); + return "done"; + } +} + +async function runInteractiveComposioDiscordBotSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const existingIsBot = + stringValue(existing["defaultApp"]) === "discord" && stringValue(existing["mode"]) === "bot"; + const existingChannelId = + stringValue(opts.channelId) ?? (existingIsBot ? stringValue(existing["channelId"]) : undefined); + const existingConnectedAccountId = existingIsBot + ? (stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"])) + : stringValue(opts.connectedAccountId); + const optionBotToken = stringValue(opts.botToken); + const canReplace = await confirmComposioDiscordWebhookConflict( + clack, + targetName, + existingPlugin, + opts.force, + "Composio Discord bot", + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "channel" | "account" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let client: ComposioSetupClient | undefined; + let channelId: string | undefined; + let connectedAccountId: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + client = await loadComposioClient(apiKey.apiKey); + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "channel"; + continue; + } + + if (step === "channel") { + const result = await promptInteractiveDiscordBotChannel( + clack, + channelId ?? existingChannelId, + ); + if (result === "back") { + step = "user-id"; + continue; + } + if (result !== existingChannelId || (channelId && channelId !== result)) { + connectedAccountId = undefined; + } + channelId = result; + step = "account"; + continue; + } + + if (step === "account") { + if (!client || !userId || !channelId) { + step = "api-key"; + continue; + } + const result = await promptInteractiveDiscordBotAccount( + clack, + client, + userId, + channelId, + connectedAccountId ?? + (channelId === existingChannelId ? existingConnectedAccountId : undefined), + optionBotToken, + ); + if (result === "back") { + step = "channel"; + continue; + } + connectedAccountId = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset( + clack, + rawConfig, + targetName, + "Composio Discord bot", + () => cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "account"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !channelId || !connectedAccountId) { + step = "api-key"; + continue; + } + + const resolved: ResolvedDiscordSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioDiscordBotReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "channel") { + step = "channel"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Discord bot connected account: ${connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template basic`)); + clack.outro("Composio Discord bot setup complete."); + return "done"; + } +} + +async function runInteractiveComposioGmailSetup( + clack: ClackPrompts, + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, + targetName = COMPOSIO_NOTIFIER, +): Promise<"back" | "done"> { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const existingPlugin = stringValue(existing["plugin"]); + const existingIsGmail = stringValue(existing["defaultApp"]) === "gmail"; + const existingEmailTo = + stringValue(opts.emailTo) ?? (existingIsGmail ? stringValue(existing["emailTo"]) : undefined); + const existingConnectedAccountId = existingIsGmail + ? (stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"])) + : stringValue(opts.connectedAccountId); + const existingAuthConfigId = + stringValue(opts.authConfigId) ?? + (existingIsGmail ? stringValue(existing["authConfigId"]) : undefined); + const canReplace = await confirmComposioDiscordWebhookConflict( + clack, + targetName, + existingPlugin, + opts.force, + "Composio Gmail", + ); + if (!canReplace) return "back"; + + const optionRoutingPreset = resolveComposioRoutingPreset(opts.routingPreset); + let step: "api-key" | "user-id" | "email" | "account" | "routing" | "review" = "api-key"; + let apiKey: ResolvedApiKey | undefined; + let userId: string | undefined; + let client: ComposioSetupClient | undefined; + let emailTo: string | undefined; + let connectedAccountId: string | undefined; + let routingPreset: NotifierRoutingPreset | undefined; + + while (true) { + if (step === "api-key") { + const result = await promptInteractiveComposioApiKey(clack, opts, existing); + if (result === "back") return "back"; + apiKey = result; + client = await loadComposioClient(apiKey.apiKey); + step = "user-id"; + continue; + } + + if (step === "user-id") { + const result = await promptInteractiveComposioUserId(clack, opts, existing); + if (result === "back") { + step = "api-key"; + continue; + } + userId = result; + step = "email"; + continue; + } + + if (step === "email") { + const result = await promptInteractiveGmailEmail(clack, emailTo ?? existingEmailTo); + if (result === "back") { + step = "user-id"; + continue; + } + emailTo = result; + step = "account"; + continue; + } + + if (step === "account") { + if (!client || !userId) { + step = "api-key"; + continue; + } + const result = await promptInteractiveGmailAccount( + clack, + client, + userId, + opts, + connectedAccountId ?? existingConnectedAccountId, + existingAuthConfigId, + ); + if (result === "back") { + step = "email"; + continue; + } + connectedAccountId = result; + step = "routing"; + continue; + } + + if (step === "routing") { + const selection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, targetName, "Composio Gmail", () => + cancelInteractiveComposioSetup(clack), + )); + if (selection === "back") { + step = "account"; + continue; + } + routingPreset = selection === "preserve" ? undefined : selection; + step = "review"; + continue; + } + + if (!apiKey || !userId || !emailTo || !connectedAccountId) { + step = "api-key"; + continue; + } + + const resolved: ResolvedMailSetup = { + apiKey: apiKey.apiKey, + shouldWriteApiKey: apiKey.shouldWriteApiKey, + userId, + emailTo, + connectedAccountId, + targetName, + routingPreset, + }; + const reviewChoice = await promptInteractiveComposioGmailReview( + clack, + resolved, + apiKey.sourceLabel, + ); + if (reviewChoice === "email") { + step = "email"; + continue; + } + if (reviewChoice === "account") { + step = "account"; + continue; + } + if (reviewChoice === "routing") { + step = "routing"; + continue; + } + if (reviewChoice === "app") { + return "back"; + } + + writeComposioMailConfig(configPath, resolved, targetName); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Gmail connected account: ${connectedAccountId}`)); + console.log(chalk.dim(`Test it with: ao notify test --to ${targetName} --template basic`)); + clack.outro("Composio Gmail setup complete."); + return "done"; + } +} + +async function showComposioAppPlaceholder( + clack: ClackPrompts, + choice: ComposioAppChoice, +): Promise<"back"> { + printComposioAppRequirements(choice); + const next = await clack.select({ + message: "What do you want to do next?", + options: [ + { value: "back", label: "Back to app choices", hint: "Pick another Composio app" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelInteractiveComposioSetup(clack); + } + return "back"; +} + +async function runInteractiveComposioSetupHub( + opts: ComposioSetupOptions, + configPath: string, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + let directChoice = getDirectComposioAppChoice(opts); + clack.intro("AO Composio notifier setup"); + + while (true) { + const choice = + directChoice ?? + (await clack.select({ + message: "Which Composio app do you want to configure?", + options: [ + { value: "slack", label: "Slack" }, + { + value: "discord-webhook", + label: "Discord webhook", + }, + { + value: "discord-bot", + label: "Discord bot", + }, + { + value: "gmail", + label: "Gmail", + }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + })); + directChoice = undefined; + + if (clack.isCancel(choice) || choice === "cancel") { + cancelInteractiveComposioSetup(clack); + } + + if (choice === "slack") { + const result = await runInteractiveComposioSlackSetup(clack, opts, configPath, rawConfig); + if (result === "done") return; + continue; + } + + if (choice === "discord-webhook") { + const result = await runInteractiveComposioDiscordWebhookSetup( + clack, + opts, + configPath, + rawConfig, + ); + if (result === "done") return; + continue; + } + + if (choice === "discord-bot") { + const result = await runInteractiveComposioDiscordBotSetup( + clack, + opts, + configPath, + rawConfig, + ); + if (result === "done") return; + continue; + } + + if (choice === "gmail") { + const result = await runInteractiveComposioGmailSetup(clack, opts, configPath, rawConfig); + if (result === "done") return; + continue; + } + + await showComposioAppPlaceholder(clack, choice as ComposioAppChoice); + } +} + +function parseDiscordWebhookUrl(webhookUrl: string): { webhookId: string; webhookToken: string } { + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new ComposioSetupError( + "Invalid Discord webhook URL. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN.", + ); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + const webhookIndex = segments.findIndex((segment) => segment === "webhooks"); + const webhookId = webhookIndex >= 0 ? segments[webhookIndex + 1] : undefined; + const webhookToken = webhookIndex >= 0 ? segments[webhookIndex + 2] : undefined; + if (!webhookId || !webhookToken) { + throw new ComposioSetupError( + "Invalid Discord webhook URL. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN.", + ); + } + + return { + webhookId: decodeURIComponent(webhookId), + webhookToken: decodeURIComponent(webhookToken), + }; +} + +async function createDiscordBearerAuthConfig( + client: ComposioSetupClient, + token: string, + name: string, +): Promise { + if (!client.authConfigs?.create) { + throw new ComposioSetupError( + "Composio SDK client does not expose authConfigs.create(); pass --connected-account-id.", + ); + } + + let authConfig: unknown; + try { + authConfig = await client.authConfigs.create(DISCORD_TOOLKIT, { + type: "use_custom_auth", + name, + authScheme: "BEARER_TOKEN", + credentials: { token }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ComposioSetupError(`Could not create a Composio Discord auth config: ${message}`); + } + + const authConfigId = isRecord(authConfig) ? stringValue(authConfig["id"]) : undefined; + if (!authConfigId) { + throw new ComposioSetupError("Could not create a Composio Discord auth config."); + } + + return authConfigId; +} + +async function createDiscordBearerConnectedAccountWithAuthConfig( + client: ComposioSetupClient, + userId: string, + authConfigId: string, + token: string, +): Promise { + if (!client.connectedAccounts.initiate) { + throw new ComposioSetupError( + "Composio SDK client does not expose connectedAccounts.initiate(); pass --connected-account-id.", + ); + } + + let request: ConnectionRequest; + try { + request = toConnectionRequest( + await client.connectedAccounts.initiate(userId, authConfigId, { + allowMultiple: true, + config: { + authScheme: "BEARER_TOKEN", + val: { + status: "ACTIVE", + token, + }, + }, + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ComposioSetupError( + `Could not create a Composio Discord connected account: ${message}`, + ); + } + + if (!request.id) { + throw new ComposioSetupError("Could not create a Composio Discord connected account."); + } + + return request.id; +} + +async function createDiscordBearerConnectedAccount( + client: ComposioSetupClient, + userId: string, + token: string, + name: string, +): Promise { + const authConfigId = await createDiscordBearerAuthConfig(client, token, name); + return createDiscordBearerConnectedAccountWithAuthConfig(client, userId, authConfigId, token); +} + +async function resolveDiscordWebhookConnectedAccountId( + client: ComposioSetupClient, + userId: string, + webhookUrl: string, + connectedAccountId?: string, + explicitConnectedAccountId = false, +): Promise { + if (connectedAccountId) { + try { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + DISCORD_TOOLKIT, + "Discord webhook", + ); + return account.id; + } catch (error) { + if (explicitConnectedAccountId) throw error; + console.log(chalk.yellow(error instanceof Error ? error.message : String(error))); + console.log(chalk.dim("Creating a new Discord webhook connected account for this userId.")); + } + } + + const { webhookToken } = parseDiscordWebhookUrl(webhookUrl); + return createDiscordBearerConnectedAccount( + client, + userId, + webhookToken, + "Discord Webhook Auth Config", + ); +} + +async function validateDiscordBotChannelAccess(botToken: string, channelId: string): Promise { + const res = await fetch(`https://discord.com/api/v10/channels/${encodeURIComponent(channelId)}`, { + headers: { + Authorization: `Bot ${botToken}`, + }, + }); + + if (res.ok) return; + + let message = `${res.status} ${res.statusText}`.trim(); + try { + const body = (await res.json()) as unknown; + if (isRecord(body) && stringValue(body["message"])) { + message = `${res.status} ${stringValue(body["message"])}`; + } + } catch { + // Keep the HTTP status message. + } + + if (res.status === 401) { + throw new ComposioSetupError(`Discord bot token is invalid (${message}).`); + } + if (res.status === 403) { + throw new ComposioSetupError( + `Discord bot cannot access channel ${channelId} (${message}). Invite the bot to the server and grant View Channel + Send Messages.`, + ); + } + throw new ComposioSetupError(`Could not validate Discord channel ${channelId}: ${message}.`); +} + +function writeComposioConfig(configPath: string, resolved: ResolvedComposioSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const targetName = resolved.targetName ?? COMPOSIO_NOTIFIER; + const existing = isRecord(notifiers[targetName]) ? notifiers[targetName] : {}; + const channel = channelConfig(resolved.channel); + + const composioConfig: Record = { + ...existing, + plugin: "composio", + defaultApp: "slack", + userId: resolved.userId, + ...channel, + }; + + if ("channelId" in channel) delete composioConfig["channelName"]; + else if ("channelName" in channel) delete composioConfig["channelId"]; + else { + delete composioConfig["channelId"]; + delete composioConfig["channelName"]; + } + + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + + if (resolved.shouldWriteApiKey) { + composioConfig["composioApiKey"] = resolved.apiKey; + } + + delete composioConfig["mode"]; + delete composioConfig["webhookUrl"]; + delete composioConfig["emailTo"]; + delete composioConfig["entityId"]; + delete composioConfig["botToken"]; + delete composioConfig["authConfigId"]; + delete composioConfig["toolVersion"]; + notifiers[targetName] = composioConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, targetName); + } + applyNotifierRoutingPreset(rawConfig, targetName, resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +function writeComposioDiscordConfig(configPath: string, resolved: ResolvedDiscordSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingRaw = notifiers[resolved.targetName]; + const existing = isRecord(existingRaw) ? existingRaw : {}; + + const composioConfig: Record = { + ...existing, + plugin: "composio", + defaultApp: "discord", + mode: resolved.mode, + userId: resolved.userId, + toolVersion: DISCORD_TOOL_VERSION, + }; + + if (resolved.mode === "webhook") { + composioConfig["webhookUrl"] = resolved.webhookUrl; + delete composioConfig["channelId"]; + delete composioConfig["channelName"]; + delete composioConfig["emailTo"]; + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + } else { + composioConfig["channelId"] = resolved.channelId; + delete composioConfig["webhookUrl"]; + delete composioConfig["channelName"]; + delete composioConfig["emailTo"]; + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + } + + if (resolved.shouldWriteApiKey) { + composioConfig["composioApiKey"] = resolved.apiKey; + } + + delete composioConfig["entityId"]; + delete composioConfig["botToken"]; + delete composioConfig["authConfigId"]; + notifiers[resolved.targetName] = composioConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, resolved.targetName); + } + applyNotifierRoutingPreset(rawConfig, resolved.targetName, resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +function writeComposioMailConfig( + configPath: string, + resolved: ResolvedMailSetup, + targetName = COMPOSIO_MAIL_NOTIFIER, +): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingRaw = notifiers[targetName]; + const existing = isRecord(existingRaw) ? existingRaw : {}; + + const composioConfig: Record = { + ...existing, + plugin: "composio", + defaultApp: "gmail", + userId: resolved.userId, + emailTo: resolved.emailTo, + toolVersion: GMAIL_TOOL_VERSION, + }; + + if (resolved.connectedAccountId) { + composioConfig["connectedAccountId"] = resolved.connectedAccountId; + } else { + delete composioConfig["connectedAccountId"]; + } + + if (resolved.shouldWriteApiKey) { + composioConfig["composioApiKey"] = resolved.apiKey; + } + + delete composioConfig["entityId"]; + delete composioConfig["channelId"]; + delete composioConfig["channelName"]; + delete composioConfig["webhookUrl"]; + delete composioConfig["mode"]; + delete composioConfig["authConfigId"]; + delete composioConfig["botToken"]; + notifiers[targetName] = composioConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, targetName); + } + applyNotifierRoutingPreset(rawConfig, targetName, resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +function printStatus( + resolved: Pick, + accounts: ConnectedAccount[], + targetName = COMPOSIO_NOTIFIER, + rawConfig?: Record, +): void { + console.log(chalk.bold(`AO Composio notifier (${targetName})`)); + console.log(" api key: configured"); + console.log(` userId: ${resolved.userId}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + if (rawConfig) console.log(` routing: ${getNotifierRoutingState(rawConfig, targetName).label}`); + console.log(` active Slack accounts: ${accounts.length}`); + for (const account of accounts) { + console.log(` - ${account.id}${account.alias ? ` (${account.alias})` : ""}`); + } +} + +function printDiscordStatus( + resolved: Pick, + rawConfig?: Record, +): void { + console.log(chalk.bold(`AO Composio Discord notifier (${resolved.targetName})`)); + console.log(" api key: configured"); + console.log(` mode: ${resolved.mode}`); + console.log(` userId: ${resolved.userId}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + if (rawConfig) { + console.log(` routing: ${getNotifierRoutingState(rawConfig, resolved.targetName).label}`); + } +} + +function printMailStatus( + resolved: Pick, + accounts: ConnectedAccount[], + rawConfig?: Record, + targetName = COMPOSIO_MAIL_NOTIFIER, +): void { + console.log(chalk.bold(`AO Composio mail notifier (${targetName})`)); + console.log(" api key: configured"); + console.log(` userId: ${resolved.userId}`); + console.log(` emailTo: ${resolved.emailTo ?? "not configured"}`); + console.log(` connectedAccountId: ${resolved.connectedAccountId ?? "not configured"}`); + if (rawConfig) console.log(` routing: ${getNotifierRoutingState(rawConfig, targetName).label}`); + console.log(` active Gmail accounts: ${accounts.length}`); + for (const account of accounts) { + console.log(` - ${account.id}${account.alias ? ` (${account.alias})` : ""}`); + } +} + +async function resolveSetup( + opts: ComposioSetupOptions, + rawConfig: Record, + nonInteractive: boolean, + targetName = COMPOSIO_NOTIFIER, +): Promise { + const existing = getExistingNotifierConfig(rawConfig, targetName); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const explicitConnectedAccountId = + stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"]); + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + + if (opts.status) { + const accounts = await listActiveSlackAccounts(client, userId); + printStatus( + { apiKey, userId, connectedAccountId: explicitConnectedAccountId }, + accounts, + targetName, + rawConfig, + ); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: explicitConnectedAccountId, + routingPreset, + }; + } + + if (explicitConnectedAccountId) { + const account = await verifyConnectedAccount(client, userId, explicitConnectedAccountId); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: account.id, + routingPreset, + }; + } + + const accounts = await listActiveSlackAccounts(client, userId); + if (accounts.length > 0) { + const account = await chooseAccount(accounts, nonInteractive); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: account.id, + routingPreset, + }; + } + + const connection = await createConnectionRequest(client, userId, parseWaitMs(opts.waitMs)); + return { + apiKey, + shouldWriteApiKey, + userId, + targetName, + channel: stringValue(opts.channel), + connectedAccountId: connection.account?.id, + connectionUrl: connection.url, + routingPreset, + }; +} + +export async function runComposioSetupAction(opts: ComposioSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const existing = getExistingComposioConfig(rawConfig); + const existingPlugin = stringValue(existing["plugin"]); + const directChoice = getDirectComposioAppChoice(opts); + + if (directChoice && nonInteractive) { + throw new ComposioSetupError( + "Composio app flags require interactive setup. Use the dedicated setup command with --non-interactive for scriptable setup.", + ); + } + + if (shouldUseInteractiveComposioHub(opts, nonInteractive)) { + await runInteractiveComposioSetupHub(opts, configPath, rawConfig); + return; + } + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.composio already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveSetup(opts, rawConfig, nonInteractive); + if (opts.status) return; + + if (resolved.connectionUrl && !resolved.connectedAccountId) { + console.log( + chalk.yellow( + "Slack connection did not complete yet. Open the connect URL above, finish the Composio flow, then rerun `ao setup composio`.", + ), + ); + console.log(chalk.dim("No config was changed.")); + return; + } + + writeComposioConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Slack connected account: ${resolved.connectedAccountId}`)); + } + + console.log(chalk.dim("Test it with: ao notify test --to composio --template ci-failing")); +} + +export async function runComposioSlackSetupAction(opts: ComposioSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, nonInteractive)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Slack setup"); + await runInteractiveComposioSlackSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_SLACK_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_SLACK_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_SLACK_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveSetup(opts, rawConfig, nonInteractive, COMPOSIO_SLACK_NOTIFIER); + if (opts.status) return; + + if (resolved.connectionUrl && !resolved.connectedAccountId) { + console.log( + chalk.yellow( + "Slack connection did not complete yet. Open the connect URL above, finish the Composio flow, then rerun `ao setup composio-slack`.", + ), + ); + console.log(chalk.dim("No config was changed.")); + return; + } + + writeComposioConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Slack connected account: ${resolved.connectedAccountId}`)); + } + + console.log( + chalk.dim(`Test it with: ao notify test --to ${COMPOSIO_SLACK_NOTIFIER} --template ci-failing`), + ); +} + +async function resolveDiscordWebhookSetup( + opts: ComposioDiscordWebhookSetupOptions, + rawConfig: Record, +): Promise { + const targetName = COMPOSIO_DISCORD_WEBHOOK_NOTIFIER; + const existing = getExistingNotifierConfig(rawConfig, targetName); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + const explicitConnectedAccountId = stringValue(opts.connectedAccountId); + const existingConnectedAccountId = stringValue(existing["connectedAccountId"]); + const connectedAccountId = explicitConnectedAccountId ?? existingConnectedAccountId; + const webhookUrl = + stringValue(opts.webhookUrl) ?? + stringValue(process.env.DISCORD_WEBHOOK_URL) ?? + stringValue(existing["webhookUrl"]); + + if (opts.status) { + printDiscordStatus({ targetName, mode: "webhook", userId, connectedAccountId }, rawConfig); + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "webhook", + targetName, + webhookUrl, + connectedAccountId, + routingPreset, + }; + } + + if (!webhookUrl) { + throw new ComposioSetupError( + "No Discord webhook URL found. Pass --webhook-url or set DISCORD_WEBHOOK_URL.", + ); + } + parseDiscordWebhookUrl(webhookUrl); + const resolvedConnectedAccountId = await resolveDiscordWebhookConnectedAccountId( + client, + userId, + webhookUrl, + connectedAccountId, + Boolean(explicitConnectedAccountId), + ); + + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "webhook", + targetName, + webhookUrl, + connectedAccountId: resolvedConnectedAccountId, + routingPreset, + }; +} + +async function resolveDiscordBotSetup( + opts: ComposioDiscordBotSetupOptions, + rawConfig: Record, +): Promise { + const targetName = COMPOSIO_DISCORD_BOT_NOTIFIER; + const existing = getExistingNotifierConfig(rawConfig, targetName); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + const connectedAccountId = + stringValue(opts.connectedAccountId) ?? stringValue(existing["connectedAccountId"]); + const channelId = stringValue(opts.channelId) ?? stringValue(existing["channelId"]); + const botToken = stringValue(opts.botToken) ?? stringValue(process.env.DISCORD_BOT_TOKEN); + + if (opts.status) { + printDiscordStatus({ targetName, mode: "bot", userId, connectedAccountId }, rawConfig); + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId, + routingPreset, + }; + } + + if (!channelId) { + throw new ComposioSetupError("No Discord channel id found. Pass --channel-id."); + } + + if (connectedAccountId) { + const account = await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + DISCORD_TOOLKIT, + "Discord Bot", + ); + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId: account.id, + routingPreset, + }; + } + + if (!botToken) { + throw new ComposioSetupError( + "No Discord bot token found. Pass --bot-token or set DISCORD_BOT_TOKEN.", + ); + } + + await validateDiscordBotChannelAccess(botToken, channelId); + + return { + apiKey, + shouldWriteApiKey, + userId, + mode: "bot", + targetName, + channelId, + connectedAccountId: await createDiscordBearerConnectedAccount( + client, + userId, + botToken, + "Discord Bot Auth Config", + ), + routingPreset, + }; +} + +async function resolveMailSetup( + opts: ComposioMailSetupOptions, + rawConfig: Record, + nonInteractive: boolean, +): Promise { + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_MAIL_NOTIFIER); + const { apiKey, shouldWriteApiKey } = resolveApiKey(opts, existing); + if (!apiKey) { + throw new ComposioSetupError( + "No Composio API key found. Pass --api-key or set COMPOSIO_API_KEY.", + ); + } + + const userId = resolveUserId(opts, existing); + const client = await loadComposioClient(apiKey); + const emailTo = stringValue(opts.emailTo) ?? stringValue(existing["emailTo"]); + const authConfigId = stringValue(opts.authConfigId) ?? stringValue(existing["authConfigId"]); + const optionConnectedAccountId = stringValue(opts.connectedAccountId); + const existingConnectedAccountId = stringValue(existing["connectedAccountId"]); + const connectedAccountId = optionConnectedAccountId ?? existingConnectedAccountId; + const routingPreset = resolveComposioRoutingPreset(opts.routingPreset) ?? "all"; + + if (opts.status) { + const accounts = await listActiveGmailAccounts(client, userId); + printMailStatus({ userId, emailTo, connectedAccountId }, accounts, rawConfig); + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + if (!emailTo) { + throw new ComposioSetupError("No recipient email found. Pass --email-to."); + } + + if (connectedAccountId) { + let account: ConnectedAccount | undefined; + try { + account = await withConnectedAccountDetails( + client, + await verifyConnectedAccountForToolkit( + client, + userId, + connectedAccountId, + GMAIL_TOOLKIT, + "Gmail", + () => listActiveGmailAccounts(client, userId), + ), + ); + } catch (err) { + if (optionConnectedAccountId) throw err; + const message = err instanceof Error ? err.message : String(err); + console.log( + chalk.yellow( + `Existing Gmail connected account ${connectedAccountId} could not be used: ${message}. Looking for another Gmail connected account.`, + ), + ); + } + + if (account && (await accountCanSendGmail(client, account))) { + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId: account.id, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + if (account && optionConnectedAccountId) { + throw new ComposioSetupError( + `Connected account ${connectedAccountId} is missing Gmail send/profile access. Connect Gmail in Composio with send access, then rerun \`ao setup composio-mail --email-to ${emailTo} --connected-account-id ${connectedAccountId}\`, or pass a different Gmail connected account.`, + ); + } + + if (account) { + console.log( + chalk.yellow( + `Existing Gmail connected account ${connectedAccountId} is missing Gmail send/profile access. Looking for another Gmail connected account.`, + ), + ); + } + } + + const accounts = await listUsableGmailAccounts(client, userId); + if (accounts.length > 0) { + const account = await chooseAccount(accounts, nonInteractive, "Gmail"); + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId: account.id, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + if (opts.connect) { + const connection = await createManagedOAuthConnectionRequest( + client, + userId, + GMAIL_TOOLKIT, + "Gmail", + "Gmail Auth Config", + parseWaitMs(opts.waitMs), + { + authConfigId: await resolveGmailConnectAuthConfigId(client, authConfigId, nonInteractive), + }, + ); + + if (connection.account) { + const account = await withConnectedAccountDetails(client, connection.account); + if (!(await accountCanSendGmail(client, account))) { + throw new ComposioSetupError( + `Connected Gmail account ${account.id} is missing Gmail send/profile access. Fix the Gmail connection in Composio, then rerun \`ao setup composio-mail\`.`, + ); + } + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectedAccountId: account.id, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + return { + apiKey, + shouldWriteApiKey, + userId, + emailTo, + connectionUrl: connection.url, + targetName: COMPOSIO_MAIL_NOTIFIER, + routingPreset, + }; + } + + throw new ComposioSetupError( + [ + `No active Gmail connected account with send access was found for user ${userId}.`, + "Connect Gmail in Composio first, then rerun `ao setup composio-mail`, or rerun with `--connect` to print a Composio connect URL.", + `You can also pass an existing Gmail account with \`ao setup composio-mail --email-to ${emailTo} --connected-account-id ca_...\`.`, + ].join(" "), + ); +} + +export async function runComposioDiscordWebhookSetupAction( + opts: ComposioDiscordWebhookSetupOptions, +): Promise { + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, opts.nonInteractive || !process.stdin.isTTY)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Discord webhook setup"); + await runInteractiveComposioDiscordWebhookSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_DISCORD_WEBHOOK_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_DISCORD_WEBHOOK_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_DISCORD_WEBHOOK_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveDiscordWebhookSetup(opts, rawConfig); + if (opts.status) return; + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green("✓ Discord webhook configured through Composio")); + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Discord webhook connected account: ${resolved.connectedAccountId}`)); + } + console.log( + chalk.dim( + `Test it with: ao notify test --to ${COMPOSIO_DISCORD_WEBHOOK_NOTIFIER} --template basic`, + ), + ); +} + +export async function runComposioDiscordBotSetupAction( + opts: ComposioDiscordBotSetupOptions, +): Promise { + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, opts.nonInteractive || !process.stdin.isTTY)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Discord bot setup"); + await runInteractiveComposioDiscordBotSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_DISCORD_BOT_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_DISCORD_BOT_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_DISCORD_BOT_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveDiscordBotSetup(opts, rawConfig); + if (opts.status) return; + + writeComposioDiscordConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + console.log(chalk.green(`✓ Discord bot connected account: ${resolved.connectedAccountId}`)); + console.log( + chalk.dim( + `Test it with: ao notify test --to ${COMPOSIO_DISCORD_BOT_NOTIFIER} --template basic`, + ), + ); +} + +export async function runComposioMailSetupAction(opts: ComposioMailSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + configPath = undefined; + } + + if (!configPath) { + throw new ComposioSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + + if (shouldUseInteractiveDedicatedSetup(opts, nonInteractive)) { + const clack = await import("@clack/prompts"); + clack.intro("AO Composio Gmail setup"); + await runInteractiveComposioGmailSetup( + clack, + opts, + configPath, + rawConfig, + COMPOSIO_MAIL_NOTIFIER, + ); + return; + } + + const existing = getExistingNotifierConfig(rawConfig, COMPOSIO_MAIL_NOTIFIER); + const existingPlugin = stringValue(existing["plugin"]); + + if (existingPlugin && existingPlugin !== "composio" && !opts.force) { + throw new ComposioSetupError( + `notifiers.${COMPOSIO_MAIL_NOTIFIER} already uses plugin "${existingPlugin}". Re-run with --force to replace it.`, + ); + } + + const resolved = await resolveMailSetup(opts, rawConfig, nonInteractive); + if (opts.status) return; + + if (resolved.connectionUrl && !resolved.connectedAccountId) { + console.log( + chalk.yellow( + "Gmail connection did not complete yet. Open the connect URL above, finish the Composio flow, then rerun `ao setup composio-mail`.", + ), + ); + console.log(chalk.dim("No config was changed.")); + return; + } + + writeComposioMailConfig(configPath, resolved); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + + if (resolved.connectedAccountId) { + console.log(chalk.green(`✓ Gmail connected account: ${resolved.connectedAccountId}`)); + } + + console.log( + chalk.dim(`Test it with: ao notify test --to ${COMPOSIO_MAIL_NOTIFIER} --template basic`), + ); +} diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index 1d1a4f11e..e02095c44 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -20,6 +20,10 @@ terminalPort: 14800 # Optional terminal WebSocket port override directTerminalPort: 14801 # Optional direct terminal WebSocket port override readyThresholdMs: 300000 # Ms before "ready" becomes "idle" (default: 5 min) +observability: + logLevel: warn # debug | info | warn | error + stderr: false # Mirror structured logs to stderr + # ── Default plugins ───────────────────────────────────────────────── # These apply to all projects unless overridden per-project. @@ -110,6 +114,12 @@ projects: notifiers: desktop: plugin: desktop + # Run 'ao setup desktop' on macOS to use AO Notifier.app + # backend: ao-app + dashboard: + plugin: dashboard + # Run 'ao setup dashboard' to retain notifications in the web dashboard + # limit: 50 slack: plugin: slack # Requires SLACK_WEBHOOK_URL env var @@ -119,8 +129,42 @@ notifiers: openclaw: plugin: openclaw # url: http://127.0.0.1:18789/hooks/agent - # token: \${OPENCLAW_HOOKS_TOKEN} + # openclawConfigPath: ~/.openclaw/openclaw.json + # OpenClaw owns hooks.token in openclaw.json # Run 'ao setup openclaw' for guided configuration + composio: + plugin: composio + # Run 'ao setup composio' to connect Slack through Composio + # userId: aoagent + # connectedAccountId: ca_... + # channelName: "#agents" + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY + # toolVersion: "20260508_00" # optional Slack override + composio-discord: + plugin: composio + # Run 'ao setup composio-discord' for Discord webhook mode through Composio + # defaultApp: discord + # mode: webhook + # webhookUrl: https://discord.com/api/webhooks/... + # userId: aoagent + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY + composio-discord-bot: + plugin: composio + # Run 'ao setup composio-discord-bot' for Discord bot mode through Composio + # defaultApp: discord + # mode: bot + # channelId: "1234567890" + # userId: aoagent + # connectedAccountId: ca_... + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY + composio-mail: + plugin: composio + # Run 'ao setup composio-mail' to connect Gmail through Composio + # defaultApp: gmail + # emailTo: alerts@example.com + # userId: aoagent + # connectedAccountId: ca_... + # composioApiKey: ak_... # optional; otherwise uses COMPOSIO_API_KEY # ── Notification routing (optional) ───────────────────────────────── # Route notifications by priority level. @@ -143,7 +187,7 @@ notificationRouting: # Workspace: worktree, clone # SCM: github, gitlab # Tracker: github, linear, gitlab -# Notifier: desktop, discord, slack, webhook, composio, openclaw +# Notifier: dashboard, desktop, discord, slack, webhook, composio, openclaw # Terminal: iterm2, web `.trim(); } diff --git a/packages/cli/src/lib/dashboard-setup.ts b/packages/cli/src/lib/dashboard-setup.ts new file mode 100644 index 000000000..49b8de230 --- /dev/null +++ b/packages/cli/src/lib/dashboard-setup.ts @@ -0,0 +1,291 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { + CONFIG_SCHEMA_URL, + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + getDashboardNotificationStorePath, + isCanonicalGlobalConfigPath, + findConfigFile, + normalizeDashboardNotificationLimit, + readDashboardNotificationsFromFile, +} from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +export interface DashboardSetupOptions { + limit?: string; + refresh?: boolean; + status?: boolean; + force?: boolean; + nonInteractive?: boolean; + routingPreset?: string; +} + +interface ConfigContext { + configPath: string; + rawConfig: Record; +} + +interface DashboardConfig { + plugin: "dashboard"; + limit: number; +} + +interface ResolvedDashboardSetup { + limit: number; + routingPreset?: NotifierRoutingPreset; +} + +export class DashboardSetupError extends Error { + constructor( + message: string, + public readonly exitCode = 1, + ) { + super(message); + this.name = "DashboardSetupError"; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function readConfigContext(): ConfigContext { + const configPath = findConfigFile() ?? undefined; + if (!configPath) { + throw new DashboardSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingDashboard(rawConfig: Record): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = notifiers["dashboard"]; + return isRecord(existing) ? existing : {}; +} + +function parseLimit(value: unknown): number { + if (value === undefined || value === null || value === "") { + return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + } + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value.trim(), 10) + : Number.NaN; + if (!Number.isFinite(parsed)) { + throw new DashboardSetupError("Dashboard notification limit must be a number."); + } + return normalizeDashboardNotificationLimit(parsed); +} + +function resolveDashboardRoutingPreset( + value: string | undefined, +): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "dashboard") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new DashboardSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function toDashboardConfig(resolved: ResolvedDashboardSetup): DashboardConfig { + return { + plugin: "dashboard", + limit: resolved.limit, + }; +} + +function writeDashboardConfig(configPath: string, resolved: ResolvedDashboardSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + notifiers["dashboard"] = toDashboardConfig(resolved); + rawConfig["notifiers"] = notifiers; + + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + rawConfig["defaults"] = defaults; + + applyNotifierRoutingPreset(rawConfig, "dashboard", resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +function cancelSetup(clack: ClackPrompts): never { + clack.cancel("Dashboard setup cancelled."); + throw new DashboardSetupError("Dashboard setup cancelled.", 0); +} + +async function shouldReplaceConflictingDashboard( + plugin: unknown, + force: boolean, + nonInteractive: boolean, +): Promise { + if (plugin === undefined || plugin === "dashboard" || force) return true; + + if (nonInteractive) { + throw new DashboardSetupError( + `notifiers.dashboard already uses plugin "${String(plugin)}". Pass --force to replace it.`, + ); + } + + const clack = await import("@clack/prompts"); + const answer = await clack.confirm({ + message: `notifiers.dashboard already uses plugin "${String(plugin)}". Replace it?`, + initialValue: false, + }); + + if (clack.isCancel(answer)) { + cancelSetup(clack); + } + + return answer === true; +} + +async function resolveInteractiveSetup( + opts: DashboardSetupOptions, + existingDashboard: Record, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + const optionRoutingPreset = resolveDashboardRoutingPreset(opts.routingPreset); + const existingLimit = parseLimit(existingDashboard["limit"]); + + clack.intro(chalk.bgCyan(chalk.black(" ao setup dashboard "))); + + while (true) { + const limitInput = await clack.text({ + message: "How many dashboard notifications should AO keep?", + placeholder: String(DEFAULT_DASHBOARD_NOTIFICATION_LIMIT), + initialValue: stringValue(opts.limit) ?? String(existingLimit), + validate: (value) => { + try { + parseLimit(value); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(limitInput)) { + cancelSetup(clack); + } + + const routingSelection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, "dashboard", "dashboard", () => + cancelSetup(clack), + )); + if (routingSelection === "back") continue; + + return { + limit: parseLimit(limitInput), + routingPreset: routingSelection === "preserve" ? undefined : routingSelection, + }; + } +} + +function resolveNonInteractiveSetup( + opts: DashboardSetupOptions, + existingDashboard: Record, +): ResolvedDashboardSetup { + const limit = + opts.limit !== undefined + ? parseLimit(opts.limit) + : opts.refresh + ? parseLimit(existingDashboard["limit"]) + : DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + const routingPreset = + resolveDashboardRoutingPreset(opts.routingPreset) ?? + (opts.refresh ? undefined : "urgent-action"); + + return { limit, routingPreset }; +} + +function printStatus(): void { + const context = readConfigContext(); + const existingDashboard = getExistingDashboard(context.rawConfig); + const plugin = stringValue(existingDashboard["plugin"]); + const limit = parseLimit(existingDashboard["limit"]); + const storePath = getDashboardNotificationStorePath(context.configPath); + const records = readDashboardNotificationsFromFile(storePath, limit); + const latest = records.at(-1); + + console.log(chalk.bold("Dashboard notifier status")); + console.log(` Config: ${context.configPath}`); + console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); + console.log(` Limit: ${limit}`); + console.log(` Store: ${storePath}`); + console.log(` Stored: ${records.length}`); + console.log(` Latest: ${latest?.receivedAt ?? chalk.dim("none")}`); + console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "dashboard").label}`); +} + +export async function runDashboardSetupAction(opts: DashboardSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const force = Boolean(opts.force); + + if (opts.status) { + printStatus(); + return; + } + + const context = readConfigContext(); + const existingDashboard = getExistingDashboard(context.rawConfig); + const shouldWire = await shouldReplaceConflictingDashboard( + existingDashboard["plugin"], + force, + nonInteractive, + ); + if (!shouldWire) return; + + const resolved = nonInteractive + ? resolveNonInteractiveSetup(opts, existingDashboard) + : await resolveInteractiveSetup(opts, existingDashboard, context.rawConfig); + + writeDashboardConfig(context.configPath, resolved); + console.log(chalk.green(`Config written to ${context.configPath}`)); + + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Dashboard setup complete!")} AO will retain the latest ${resolved.limit} dashboard notifications.\n` + + chalk.dim(" Test it with: ao notify test --to dashboard --template basic"), + ); + } else { + console.log(chalk.green("\nDashboard setup complete.")); + console.log(chalk.dim("Test it with: ao notify test --to dashboard --template basic")); + } +} diff --git a/packages/cli/src/lib/desktop-setup.ts b/packages/cli/src/lib/desktop-setup.ts new file mode 100644 index 000000000..7b2d2e922 --- /dev/null +++ b/packages/cli/src/lib/desktop-setup.ts @@ -0,0 +1,725 @@ +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { homedir, platform } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +const APP_NAME = "AO Notifier.app"; +const EXECUTABLE_NAME = "ao-notifier"; +const PLACEHOLDER_MARKER_NAME = "ao-notifier-placeholder"; +const DESKTOP_BACKENDS = ["auto", "ao-app", "terminal-notifier", "osascript"] as const; + +type DesktopBackend = (typeof DESKTOP_BACKENDS)[number]; + +export class DesktopSetupError extends Error { + constructor( + message: string, + public readonly exitCode: number = 1, + ) { + super(message); + this.name = "DesktopSetupError"; + } +} + +export interface DesktopSetupOptions { + nonInteractive?: boolean; + force?: boolean; + status?: boolean; + uninstall?: boolean; + refresh?: boolean; + backend?: string; + dashboardUrl?: string; + appPath?: string; + test?: boolean; + routingPreset?: string; +} + +interface JsonRecord { + [key: string]: unknown; +} + +interface DesktopConfigContext { + configPath: string | undefined; + rawConfig: Record; + existingDesktop: Record; +} + +interface ResolvedDesktopSetup { + backend: DesktopBackend; + dashboardUrl?: string; + appPath: string; + shouldWriteAppPath: boolean; + shouldSendTest: boolean; + refresh: boolean; + routingPreset?: NotifierRoutingPreset; +} + +function currentPlatform(): NodeJS.Platform | string { + return process.env["AO_DESKTOP_SETUP_PLATFORM"] ?? platform(); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function isDesktopBackend(value: unknown): value is DesktopBackend { + return typeof value === "string" && DESKTOP_BACKENDS.includes(value as DesktopBackend); +} + +function parseDesktopBackend(value: unknown): DesktopBackend | undefined { + if (value === undefined) return undefined; + if (isDesktopBackend(value)) return value; + throw new DesktopSetupError( + `Invalid desktop backend "${String(value)}". Expected one of: ${DESKTOP_BACKENDS.join(", ")}.`, + ); +} + +function packageDirFromImport(): string | null { + try { + const require = createRequire(import.meta.url); + return dirname(require.resolve("@aoagents/ao-notifier-macos/package.json")); + } catch { + return null; + } +} + +export function getBundledNotifierAppPath(): string | null { + const override = process.env["AO_NOTIFIER_MACOS_APP_PATH"]; + if (override) return override; + + const packageDir = packageDirFromImport(); + if (packageDir) { + return resolve(packageDir, "dist", APP_NAME); + } + + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", "..", "notifier-macos", "dist", APP_NAME); +} + +export function getInstalledNotifierAppPath(): string { + return process.env["AO_DESKTOP_APP_INSTALL_PATH"] ?? join(homedir(), "Applications", APP_NAME); +} + +export function getNotifierExecutablePath(appPath: string): string { + return join(appPath, "Contents", "MacOS", EXECUTABLE_NAME); +} + +function getNotifierPlaceholderMarkerPath(appPath: string): string { + return join(appPath, "Contents", "Resources", PLACEHOLDER_MARKER_NAME); +} + +function isPlaceholderNotifierApp(appPath: string): boolean { + return existsSync(getNotifierPlaceholderMarkerPath(appPath)); +} + +function isAppInstalled(appPath = getInstalledNotifierAppPath()): boolean { + return existsSync(getNotifierExecutablePath(appPath)) && !isPlaceholderNotifierApp(appPath); +} + +function commandExists(command: string): boolean { + try { + execFileSync(command, ["--version"], { stdio: "ignore", windowsHide: true }); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } +} + +function execNotifierJson(appPath: string, args: string[]): JsonRecord | null { + try { + const output = execFileSync(getNotifierExecutablePath(appPath), args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + return JSON.parse(output) as JsonRecord; + } catch { + return null; + } +} + +function parseJsonOutput(output: unknown): JsonRecord | null { + try { + const text = Buffer.isBuffer(output) ? output.toString("utf-8") : String(output ?? ""); + if (!text.trim()) return null; + return JSON.parse(text) as JsonRecord; + } catch { + return null; + } +} + +function formatExecError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function permissionDeniedMessage(): string { + return ( + "macOS notification permission is denied for AO Notifier.app.\n" + + " Open System Settings > Notifications > AO Notifier and enable Allow Notifications.\n" + + " Then rerun: ao setup desktop --force" + ); +} + +function readConfigContext(): DesktopConfigContext { + const configPath = findOptionalConfigPath(); + if (!configPath) { + return { + configPath, + rawConfig: {}, + existingDesktop: {}, + }; + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const rawConfig = (parseDocument(rawYaml).toJS() as Record) ?? {}; + const notifiers = (rawConfig["notifiers"] as Record | undefined) ?? {}; + const existingDesktop = (notifiers["desktop"] as Record | undefined) ?? {}; + return { + configPath, + rawConfig, + existingDesktop, + }; +} + +function printStatus(): void { + const os = currentPlatform(); + const appPath = getInstalledNotifierAppPath(); + const installed = isAppInstalled(appPath); + const version = installed ? execNotifierJson(appPath, ["--version-json"]) : null; + const permission = installed ? execNotifierJson(appPath, ["--permission-status-json"]) : null; + const context = readConfigContext(); + const configBackend = stringValue(context.existingDesktop["backend"]); + const configDashboardUrl = stringValue(context.existingDesktop["dashboardUrl"]); + const configAppPath = stringValue(context.existingDesktop["appPath"]); + + console.log(chalk.bold("AO desktop notifier")); + console.log(` platform: ${os}`); + if (context.configPath) { + console.log(` config: ${context.configPath}`); + } + console.log(` config backend: ${configBackend ?? "not configured"}`); + console.log(` dashboardUrl: ${configDashboardUrl ?? "not configured"}`); + console.log(` config appPath: ${configAppPath ?? "not configured"}`); + console.log(` terminal-notifier: ${commandExists("terminal-notifier") ? "available" : "missing"}`); + console.log(` osascript: ${commandExists("osascript") ? "available" : "missing"}`); + console.log(` installed: ${installed ? "yes" : "no"}`); + console.log(` app: ${appPath}`); + console.log(` routing: ${getNotifierRoutingState(context.rawConfig, "desktop").label}`); + if (version?.["version"]) { + console.log(` version: ${String(version["version"])}`); + } + if (permission?.["status"]) { + console.log(` permissions: ${String(permission["status"])}`); + } +} + +function copyBundledApp(targetAppPath = getInstalledNotifierAppPath()): string { + if (currentPlatform() !== "darwin") { + throw new DesktopSetupError("ao setup desktop is currently only supported on macOS."); + } + + const sourceAppPath = getBundledNotifierAppPath(); + if (!sourceAppPath || !existsSync(getNotifierExecutablePath(sourceAppPath))) { + throw new DesktopSetupError( + "AO Notifier.app is not built. Run: pnpm --filter @aoagents/ao-notifier-macos build", + ); + } + + if (isPlaceholderNotifierApp(sourceAppPath)) { + throw new DesktopSetupError( + "AO Notifier.app was built as a non-macOS placeholder and cannot be installed. " + + "Rebuild @aoagents/ao-notifier-macos on macOS, or use --backend terminal-notifier.", + ); + } + + mkdirSync(dirname(targetAppPath), { recursive: true }); + rmSync(targetAppPath, { recursive: true, force: true }); + cpSync(sourceAppPath, targetAppPath, { recursive: true }); + + if (!isAppInstalled(targetAppPath)) { + throw new DesktopSetupError(`AO Notifier.app install failed at ${targetAppPath}`); + } + + return targetAppPath; +} + +function requestPermission(appPath: string): void { + let result: JsonRecord | null; + + try { + const output = execFileSync(getNotifierExecutablePath(appPath), ["--request-permission"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + result = parseJsonOutput(output); + } catch (error) { + const failure = error as { stdout?: unknown; stderr?: unknown }; + result = parseJsonOutput(failure.stdout); + if (result?.["status"] === "denied") { + throw new DesktopSetupError(permissionDeniedMessage()); + } + + const stderr = Buffer.isBuffer(failure.stderr) + ? failure.stderr.toString("utf-8").trim() + : String(failure.stderr ?? "").trim(); + throw new DesktopSetupError( + `Could not request macOS notification permission: ${stderr || formatExecError(error)}`, + ); + } + + if (result?.["status"] === "denied") { + throw new DesktopSetupError(permissionDeniedMessage()); + } +} + +function sendAoAppSetupNotification(appPath: string, dashboardUrl?: string): void { + const payload = { + title: "AO Notifier", + body: "Desktop notifications are ready.", + sound: false, + defaultOpenUrl: dashboardUrl, + event: { + id: `desktop-setup-${Date.now()}`, + type: "setup.desktop", + priority: "info", + sessionId: "setup", + projectId: "ao", + timestamp: new Date().toISOString(), + }, + actions: dashboardUrl ? [{ label: "Open Dashboard", url: dashboardUrl }] : [], + }; + const encoded = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64"); + try { + execFileSync(getNotifierExecutablePath(appPath), ["--notify-base64", encoded], { + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const failure = error as { stderr?: unknown }; + const stderr = Buffer.isBuffer(failure.stderr) + ? failure.stderr.toString("utf-8").trim() + : String(failure.stderr ?? "").trim(); + throw new DesktopSetupError( + `Could not send desktop setup test notification: ${stderr || formatExecError(error)}`, + ); + } +} + +function sendTerminalNotifierSetupNotification(dashboardUrl?: string): void { + const args = ["-title", "AO Notifier", "-message", "Desktop notifications are ready."]; + if (dashboardUrl) args.push("-open", dashboardUrl); + execFileSync("terminal-notifier", args, { stdio: ["ignore", "pipe", "pipe"] }); +} + +function sendOsascriptSetupNotification(): void { + execFileSync( + "osascript", + ["-e", 'display notification "Desktop notifications are ready." with title "AO Notifier"'], + { stdio: ["ignore", "pipe", "pipe"] }, + ); +} + +function findOptionalConfigPath(): string | undefined { + try { + return findConfigFile() ?? undefined; + } catch { + return undefined; + } +} + +async function shouldReplaceConflictingDesktop( + existingPlugin: unknown, + force: boolean, + nonInteractive: boolean, +): Promise { + if (existingPlugin === undefined || existingPlugin === "desktop" || force) return true; + if (nonInteractive) { + throw new DesktopSetupError( + `notifiers.desktop already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`, + ); + } + + const clack = await import("@clack/prompts"); + const replace = await clack.confirm({ + message: `notifiers.desktop already uses plugin "${String(existingPlugin)}". Replace it?`, + initialValue: false, + }); + + if (clack.isCancel(replace) || !replace) { + console.log(chalk.dim("Keeping existing desktop notifier config.")); + return false; + } + + return true; +} + +function dashboardUrlFromConfig(rawConfig: Record): string | undefined { + const port = rawConfig["port"]; + if (typeof port === "number") return `http://localhost:${port}`; + if (typeof port === "string" && port.trim().length > 0) return `http://localhost:${port.trim()}`; + return undefined; +} + +async function chooseDesktopBackend( + existingBackend: DesktopBackend | undefined, + nonInteractive: boolean, +): Promise { + if (nonInteractive) return existingBackend ?? "ao-app"; + + const clack = await import("@clack/prompts"); + const choice = await clack.select({ + message: "Choose the desktop notification backend:", + options: [ + { + value: "ao-app", + label: existingBackend === "ao-app" ? "AO Notifier.app (current)" : "AO Notifier.app (recommended)", + hint: "Native macOS app with actions and AO-specific behavior", + }, + { + value: "auto", + label: existingBackend === "auto" ? "Auto fallback (current)" : "Auto fallback", + hint: "AO app if installed, then terminal-notifier, then osascript", + }, + { + value: "terminal-notifier", + label: existingBackend === "terminal-notifier" ? "terminal-notifier (current)" : "terminal-notifier", + hint: "Requires Homebrew package; supports click-to-open", + }, + { + value: "osascript", + label: existingBackend === "osascript" ? "osascript (current)" : "osascript", + hint: "Built into macOS; basic notifications only", + }, + ], + }); + + if (clack.isCancel(choice)) { + clack.cancel("Setup cancelled."); + throw new DesktopSetupError("Setup cancelled.", 0); + } + + return choice as DesktopBackend; +} + +function resolveDashboardUrl( + opts: DesktopSetupOptions, + rawConfig: Record, + existingDesktop: Record, +): string | undefined { + return ( + stringValue(opts.dashboardUrl) ?? + dashboardUrlFromConfig(rawConfig) ?? + stringValue(existingDesktop["dashboardUrl"]) + ); +} + +async function maybeInstallTerminalNotifier(nonInteractive: boolean): Promise { + if (commandExists("terminal-notifier")) return; + + if (nonInteractive) { + throw new DesktopSetupError( + "terminal-notifier is not installed. Install it with: brew install terminal-notifier", + ); + } + + const clack = await import("@clack/prompts"); + const install = await clack.confirm({ + message: "terminal-notifier is not installed. Install it with Homebrew now?", + initialValue: true, + }); + + if (clack.isCancel(install) || !install) { + throw new DesktopSetupError( + "terminal-notifier is required for this backend. Install it with: brew install terminal-notifier", + ); + } + + try { + execFileSync("brew", ["install", "terminal-notifier"], { stdio: "inherit" }); + } catch (error) { + throw new DesktopSetupError( + `Could not install terminal-notifier with Homebrew: ${formatExecError(error)}`, + ); + } +} + +function assertOsascriptAvailable(): void { + if (!commandExists("osascript")) { + throw new DesktopSetupError("osascript is not available on this system."); + } +} + +function resolveAutoBackend(appPath: string): DesktopBackend { + if (currentPlatform() === "darwin" && isAppInstalled(appPath)) return "ao-app"; + if (commandExists("terminal-notifier")) return "terminal-notifier"; + if (commandExists("osascript")) return "osascript"; + throw new DesktopSetupError( + "No desktop notification backend is available. Run `ao setup desktop --backend ao-app`, or install terminal-notifier.", + ); +} + +async function resolveDesktopSetup( + opts: DesktopSetupOptions, + context: DesktopConfigContext, + nonInteractive: boolean, +): Promise { + const explicitBackend = parseDesktopBackend(opts.backend); + const existingBackend = parseDesktopBackend(context.existingDesktop["backend"]); + const optionRoutingPreset = resolveDesktopRoutingPreset(opts.routingPreset); + + while (true) { + const backend = + explicitBackend ?? + (await chooseDesktopBackend(opts.refresh ? existingBackend : undefined, nonInteractive)); + const appPath = + stringValue(opts.appPath) ?? + stringValue(context.existingDesktop["appPath"]) ?? + getInstalledNotifierAppPath(); + const routingSelection = + optionRoutingPreset ?? + (nonInteractive || !context.configPath + ? opts.refresh + ? undefined + : "all" + : await promptNotifierRoutingPreset( + await import("@clack/prompts"), + context.rawConfig, + "desktop", + "desktop", + () => { + throw new DesktopSetupError("Setup cancelled.", 0); + }, + )); + + if (routingSelection === "back") continue; + + return { + backend, + dashboardUrl: resolveDashboardUrl(opts, context.rawConfig, context.existingDesktop), + appPath, + shouldWriteAppPath: + Boolean(stringValue(opts.appPath)) || + stringValue(context.existingDesktop["appPath"]) !== undefined, + shouldSendTest: opts.test !== false, + refresh: Boolean(opts.refresh), + routingPreset: routingSelection === "preserve" ? undefined : routingSelection, + }; + } +} + +function resolveDesktopRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "desktop") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new DesktopSetupError(error instanceof Error ? error.message : String(error)); + } +} + +async function prepareDesktopBackend( + resolved: ResolvedDesktopSetup, + force: boolean, + nonInteractive: boolean, +): Promise { + if (currentPlatform() !== "darwin") { + throw new DesktopSetupError("ao setup desktop is currently only supported on macOS."); + } + + if (resolved.backend === "ao-app") { + const shouldInstall = !resolved.refresh || force || !isAppInstalled(resolved.appPath); + if (shouldInstall) { + const appPath = copyBundledApp(resolved.appPath); + console.log(chalk.green(`✓ Installed ${APP_NAME} to ${appPath}`)); + } else { + console.log(chalk.green(`✓ ${APP_NAME} is installed at ${resolved.appPath}`)); + } + requestPermission(resolved.appPath); + console.log(chalk.green("✓ Notification permission checked")); + return "ao-app"; + } + + if (resolved.backend === "terminal-notifier") { + await maybeInstallTerminalNotifier(nonInteractive); + console.log(chalk.green("✓ terminal-notifier is available")); + return "terminal-notifier"; + } + + if (resolved.backend === "osascript") { + assertOsascriptAvailable(); + console.log(chalk.green("✓ osascript is available")); + return "osascript"; + } + + const effectiveBackend = resolveAutoBackend(resolved.appPath); + if (effectiveBackend === "ao-app") { + requestPermission(resolved.appPath); + console.log(chalk.green(`✓ auto backend resolved to ${APP_NAME}`)); + } else if (effectiveBackend === "terminal-notifier") { + console.log(chalk.green("✓ auto backend resolved to terminal-notifier")); + } else { + console.log(chalk.green("✓ auto backend resolved to osascript")); + } + return effectiveBackend; +} + +function sendBackendSetupNotification( + effectiveBackend: DesktopBackend, + resolved: ResolvedDesktopSetup, +): void { + try { + if (effectiveBackend === "ao-app") { + sendAoAppSetupNotification(resolved.appPath, resolved.dashboardUrl); + } else if (effectiveBackend === "terminal-notifier") { + sendTerminalNotifierSetupNotification(resolved.dashboardUrl); + } else if (effectiveBackend === "osascript") { + sendOsascriptSetupNotification(); + } + } catch (error) { + throw new DesktopSetupError( + `Could not send desktop setup test notification: ${formatExecError(error)}`, + ); + } +} + +async function wireDesktopConfig( + configPath: string | undefined, + force: boolean, + nonInteractive: boolean, + resolved: ResolvedDesktopSetup, + conflictAlreadyChecked = false, +): Promise { + if (!configPath) { + console.log(chalk.dim("No agent-orchestrator.yaml found; skipping config wiring.")); + return false; + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = (rawConfig["notifiers"] as Record | undefined) ?? {}; + const existingDesktop = (notifiers["desktop"] as Record | undefined) ?? {}; + + if ( + !conflictAlreadyChecked && + !(await shouldReplaceConflictingDesktop( + existingDesktop["plugin"], + force, + nonInteractive, + )) + ) { + return false; + } + + const desktopConfig: Record = { + ...existingDesktop, + plugin: "desktop", + backend: resolved.backend, + }; + if (resolved.dashboardUrl) desktopConfig["dashboardUrl"] = resolved.dashboardUrl; + if (resolved.shouldWriteAppPath) desktopConfig["appPath"] = resolved.appPath; + + notifiers["desktop"] = desktopConfig; + rawConfig["notifiers"] = notifiers; + + const defaults = (rawConfig["defaults"] as Record | undefined) ?? {}; + rawConfig["defaults"] = defaults; + + applyNotifierRoutingPreset(rawConfig, "desktop", resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + + writeFileSync(configPath, doc.toString({ indent: 2 })); + console.log(chalk.green(`✓ Config written to ${configPath}`)); + return true; +} + +async function canWireDesktopConfig( + configPath: string | undefined, + force: boolean, + nonInteractive: boolean, +): Promise { + if (!configPath) return false; + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + const notifiers = (rawConfig["notifiers"] as Record | undefined) ?? {}; + const existingDesktop = (notifiers["desktop"] as Record | undefined) ?? {}; + return shouldReplaceConflictingDesktop(existingDesktop["plugin"], force, nonInteractive); +} + +function uninstallDesktopApp(): void { + const appPath = getInstalledNotifierAppPath(); + rmSync(appPath, { recursive: true, force: true }); + console.log(chalk.green(`✓ Removed ${appPath}`)); + console.log(chalk.dim("AO config was not changed.")); +} + +export async function runDesktopSetupAction(opts: DesktopSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const force = Boolean(opts.force); + + if (opts.status) { + printStatus(); + return; + } + + if (opts.uninstall) { + uninstallDesktopApp(); + return; + } + + const context = readConfigContext(); + const shouldWireConfig = await canWireDesktopConfig(context.configPath, force, nonInteractive); + if (context.configPath && !shouldWireConfig) { + console.log(chalk.dim("Skipped config wiring.")); + return; + } + + const resolved = await resolveDesktopSetup(opts, context, nonInteractive); + const effectiveBackend = await prepareDesktopBackend(resolved, force, nonInteractive); + + if (resolved.shouldSendTest) { + sendBackendSetupNotification(effectiveBackend, resolved); + console.log(chalk.green("✓ Sent desktop setup test notification")); + } else { + console.log(chalk.dim("Skipped desktop setup test notification.")); + } + + if (shouldWireConfig) { + await wireDesktopConfig(context.configPath, force, nonInteractive, resolved, true); + } else if (!context.configPath) { + console.log(chalk.dim("No agent-orchestrator.yaml found; skipping config wiring.")); + } else { + console.log(chalk.dim("Skipped config wiring.")); + } + + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Desktop setup complete!")} AO will use ${resolved.backend} for desktop notifications.\n` + + chalk.dim(" Test it with: ao notify test --to desktop --template basic"), + ); + } else { + console.log(chalk.green("\n✓ Desktop setup complete.")); + console.log(chalk.dim("Test it with: ao notify test --to desktop --template basic")); + } +} diff --git a/packages/cli/src/lib/discord-setup.ts b/packages/cli/src/lib/discord-setup.ts new file mode 100644 index 000000000..840d5f180 --- /dev/null +++ b/packages/cli/src/lib/discord-setup.ts @@ -0,0 +1,753 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +const DISCORD_APP_URL = "https://discord.com/app"; +const DISCORD_SUPPORT_WEBHOOK_URL = + "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"; +const DISCORD_WEBHOOK_DOCS_URL = "https://docs.discord.com/developers/resources/webhook"; +const DEFAULT_USERNAME = "Agent Orchestrator"; +const DEFAULT_RETRIES = 2; +const DEFAULT_RETRY_DELAY_MS = 1000; +const SETUP_TIMEOUT_MS = 10_000; + +export interface DiscordSetupOptions { + webhookUrl?: string; + username?: string; + avatarUrl?: string; + threadId?: string; + retries?: string; + retryDelayMs?: string; + refresh?: boolean; + status?: boolean; + test?: boolean; + force?: boolean; + nonInteractive?: boolean; + routingPreset?: string; +} + +interface ConfigContext { + configPath: string; + rawConfig: Record; +} + +interface ResolvedDiscordSetup { + webhookUrl: string; + username: string; + avatarUrl?: string; + threadId?: string; + retries: number; + retryDelayMs: number; + shouldSendTest: boolean; + routingPreset?: NotifierRoutingPreset; +} + +export class DiscordSetupError extends Error { + constructor( + message: string, + public readonly exitCode = 1, + ) { + super(message); + this.name = "DiscordSetupError"; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function validateDiscordWebhookUrl(webhookUrl: string): void { + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new DiscordSetupError("Discord webhook URL is invalid."); + } + + const validHost = parsed.hostname === "discord.com" || parsed.hostname === "discordapp.com"; + if (parsed.protocol !== "https:" || !validHost || !parsed.pathname.startsWith("/api/webhooks/")) { + throw new DiscordSetupError( + "Discord webhook URL must look like https://discord.com/api/webhooks/...", + ); + } +} + +function validateOptionalHttpUrl(value: string | undefined, label: string): void { + if (!value) return; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new DiscordSetupError(`${label} is invalid.`); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new DiscordSetupError(`${label} must start with http:// or https://.`); + } +} + +function parseNonNegativeInteger(value: unknown, label: string, fallback: number): number { + if (value === undefined || value === null || value === "") return fallback; + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new DiscordSetupError(`${label} must be a non-negative integer.`); + } + return parsed; +} + +function existingIntegerText(value: unknown, fallback: number): string { + if (value === undefined || value === null || value === "") return String(fallback); + const parsed = typeof value === "number" ? value : Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? String(parsed) : String(fallback); +} + +function readConfigContext(): ConfigContext { + const configPath = findConfigFile() ?? undefined; + if (!configPath) { + throw new DiscordSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingDiscord(rawConfig: Record): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = notifiers["discord"]; + return isRecord(existing) ? existing : {}; +} + +function formatFetchError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function effectiveWebhookUrl(webhookUrl: string, threadId?: string): string { + if (!threadId) return webhookUrl; + const separator = webhookUrl.includes("?") ? "&" : "?"; + return `${webhookUrl}${separator}thread_id=${encodeURIComponent(threadId)}`; +} + +function buildSetupPayload(resolved: ResolvedDiscordSetup): Record { + const payload: Record = { + username: resolved.username, + content: "AO Discord notifications are ready.", + embeds: [ + { + title: "AO Discord notifications are ready", + description: "This channel is now configured to receive AO notifications.", + color: 0x57f287, + timestamp: new Date().toISOString(), + footer: { text: "Agent Orchestrator" }, + }, + ], + }; + if (resolved.avatarUrl) payload["avatar_url"] = resolved.avatarUrl; + return payload; +} + +async function sendSetupProbe(resolved: ResolvedDiscordSetup): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS); + const url = effectiveWebhookUrl(resolved.webhookUrl, resolved.threadId); + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildSetupPayload(resolved)), + signal: controller.signal, + }); + + if (response.ok || response.status === 204) return; + + const body = await response.text().catch(() => ""); + throw new DiscordSetupError( + `Discord setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`, + ); + } catch (error) { + if (error instanceof DiscordSetupError) throw error; + throw new DiscordSetupError(`Discord setup test failed: ${formatFetchError(error)}`); + } finally { + clearTimeout(timer); + } +} + +async function shouldReplaceConflictingDiscord( + existingPlugin: unknown, + force: boolean, + nonInteractive: boolean, +): Promise { + if (existingPlugin === undefined || existingPlugin === "discord" || force) return true; + if (nonInteractive) { + throw new DiscordSetupError( + `notifiers.discord already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`, + ); + } + + const clack = await import("@clack/prompts"); + const replace = await clack.confirm({ + message: `notifiers.discord already uses plugin "${String(existingPlugin)}". Replace it?`, + initialValue: false, + }); + + if (clack.isCancel(replace) || !replace) { + console.log(chalk.dim("Keeping existing Discord notifier config.")); + return false; + } + + return true; +} + +function printManualWebhookInstructions(): void { + console.log(""); + console.log(chalk.bold("Create a Discord incoming webhook")); + console.log(` 1. Open ${DISCORD_APP_URL}`); + console.log(" 2. Open the target server and channel."); + console.log(" 3. Open Edit Channel > Integrations > Webhooks."); + console.log(" 4. Create a new webhook."); + console.log(" 5. Copy the webhook URL and paste it here."); + console.log(chalk.dim(`Discord help: ${DISCORD_SUPPORT_WEBHOOK_URL}`)); + console.log(chalk.dim(`Developer docs: ${DISCORD_WEBHOOK_DOCS_URL}`)); + console.log(""); +} + +function explainChannelBinding(): void { + console.log( + chalk.dim( + "Discord webhook URLs are bound to the channel selected in Discord. To change channels, create a webhook in that channel.", + ), + ); +} + +function cancelSetup(clack: ClackPrompts): never { + clack.cancel("Setup cancelled."); + throw new DiscordSetupError("Setup cancelled.", 0); +} + +async function promptDiscordWebhookUrl( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const webhookUrlInput = await clack.text({ + message: "Discord webhook URL:", + placeholder: "https://discord.com/api/webhooks/...", + initialValue, + validate: (value) => { + if (!value) return "Discord webhook URL is required"; + try { + validateDiscordWebhookUrl(String(value)); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(webhookUrlInput)) { + cancelSetup(clack); + } + + return String(webhookUrlInput); +} + +async function promptAfterWebhookInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printManualWebhookInstructions(); + + while (true) { + const next = await clack.select({ + message: "After creating the Discord webhook, what do you want to do?", + options: [ + { + value: "enter-url", + label: "Paste webhook URL", + hint: "Continue setup", + }, + { + value: "show-steps", + label: "Show steps again", + hint: "Reprint the Discord links and steps", + }, + { + value: "back", + label: "Back", + hint: "Return to the previous options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelSetup(clack); + } + + if (next === "back") return "back"; + if (next === "show-steps") { + printManualWebhookInstructions(); + continue; + } + if (next === "enter-url") { + return promptDiscordWebhookUrl(clack, initialValue); + } + } +} + +async function promptChangeWebhookUrl( + clack: ClackPrompts, + existingWebhookUrl: string | undefined, +): Promise { + while (true) { + const next = await clack.select({ + message: "How do you want to change the Discord webhook URL?", + options: [ + { + value: "enter-url", + label: "Paste new webhook URL", + hint: "Use a different Discord webhook URL", + }, + { + value: "need-url", + label: "Show me how to create a new webhook", + hint: "AO will print Discord steps and wait", + }, + { + value: "back", + label: "Back", + hint: "Return to the previous options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelSetup(clack); + } + + if (next === "back") return "back"; + if (next === "enter-url") { + return promptDiscordWebhookUrl(clack, existingWebhookUrl); + } + if (next === "need-url") { + const result = await promptAfterWebhookInstructions(clack, undefined); + if (result === "back") continue; + return result; + } + } +} + +async function resolveInteractiveWebhookUrl( + clack: ClackPrompts, + opts: DiscordSetupOptions, + existingWebhookUrl: string | undefined, +): Promise { + const providedWebhookUrl = stringValue(opts.webhookUrl); + if (providedWebhookUrl) return providedWebhookUrl; + + while (true) { + const source = existingWebhookUrl + ? await clack.select({ + message: "Discord notifier is already configured. What do you want to do?", + options: [ + { + value: "use-existing", + label: "Use existing webhook URL", + hint: "Keep sending to the currently configured Discord channel", + }, + { + value: "change-url", + label: "Change webhook URL", + hint: "Paste a different Discord webhook URL", + }, + { + value: "need-url", + label: "Show me how to create a new webhook", + hint: "AO will print Discord steps and wait", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }) + : await clack.select({ + message: "Do you already have a Discord webhook URL?", + options: [ + { + value: "have-url", + label: "Yes, I have the URL", + hint: "Paste the existing Discord webhook URL", + }, + { + value: "need-url", + label: "No, show me how to create one", + hint: "AO will print Discord steps and wait", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(source) || source === "cancel") { + cancelSetup(clack); + } + + if (source === "use-existing" && existingWebhookUrl) { + return existingWebhookUrl; + } + + if (source === "change-url") { + const result = await promptChangeWebhookUrl(clack, existingWebhookUrl); + if (result === "back") continue; + return result; + } + + if (source === "have-url") { + return promptDiscordWebhookUrl(clack, undefined); + } + + if (source === "need-url") { + const result = await promptAfterWebhookInstructions(clack, undefined); + if (result === "back") continue; + return result; + } + } +} + +async function resolveInteractiveSetup( + opts: DiscordSetupOptions, + existingDiscord: Record, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + const existingWebhookUrl = stringValue(existingDiscord["webhookUrl"]); + const optionRoutingPreset = resolveDiscordRoutingPreset(opts.routingPreset); + + clack.intro(chalk.bgCyan(chalk.black(" ao setup discord "))); + explainChannelBinding(); + + while (true) { + const resolvedWebhookUrl = await resolveInteractiveWebhookUrl(clack, opts, existingWebhookUrl); + + const usernameInput = await clack.text({ + message: "Display name AO should request for Discord messages:", + placeholder: DEFAULT_USERNAME, + initialValue: + stringValue(opts.username) ?? stringValue(existingDiscord["username"]) ?? DEFAULT_USERNAME, + }); + + if (clack.isCancel(usernameInput)) { + cancelSetup(clack); + } + + const avatarUrlInput = await clack.text({ + message: "Avatar image URL (optional):", + placeholder: "https://example.com/avatar.png", + initialValue: stringValue(opts.avatarUrl) ?? stringValue(existingDiscord["avatarUrl"]), + validate: (value) => { + if (!value) return undefined; + try { + validateOptionalHttpUrl(String(value), "Avatar URL"); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(avatarUrlInput)) { + cancelSetup(clack); + } + + const threadIdInput = await clack.text({ + message: "Thread ID (optional; posts into an existing Discord thread):", + placeholder: "1234567890", + initialValue: stringValue(opts.threadId) ?? stringValue(existingDiscord["threadId"]), + }); + + if (clack.isCancel(threadIdInput)) { + cancelSetup(clack); + } + + const retriesInput = await clack.text({ + message: "Retries for rate limits, network errors, and 5xx responses:", + placeholder: String(DEFAULT_RETRIES), + initialValue: + stringValue(opts.retries) ?? + existingIntegerText(existingDiscord["retries"], DEFAULT_RETRIES), + validate: (value) => { + try { + parseNonNegativeInteger(value, "Retries", DEFAULT_RETRIES); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(retriesInput)) { + cancelSetup(clack); + } + + const retryDelayInput = await clack.text({ + message: "Base retry delay in milliseconds:", + placeholder: String(DEFAULT_RETRY_DELAY_MS), + initialValue: + stringValue(opts.retryDelayMs) ?? + existingIntegerText(existingDiscord["retryDelayMs"], DEFAULT_RETRY_DELAY_MS), + validate: (value) => { + try { + parseNonNegativeInteger(value, "Retry delay", DEFAULT_RETRY_DELAY_MS); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(retryDelayInput)) { + cancelSetup(clack); + } + + const routingSelection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, "discord", "Discord", () => + cancelSetup(clack), + )); + if (routingSelection === "back") continue; + + return buildResolvedSetup( + String(resolvedWebhookUrl), + stringValue(usernameInput), + stringValue(avatarUrlInput), + stringValue(threadIdInput), + retriesInput, + retryDelayInput, + routingSelection === "preserve" ? undefined : routingSelection, + opts, + ); + } +} + +function resolveNonInteractiveSetup( + opts: DiscordSetupOptions, + existingDiscord: Record, +): ResolvedDiscordSetup { + const webhookUrl = + stringValue(opts.webhookUrl) ?? + (opts.refresh ? stringValue(existingDiscord["webhookUrl"]) : undefined); + if (!webhookUrl) { + throw new DiscordSetupError( + "Discord webhook URL is required. Pass --webhook-url, or run `ao setup discord --refresh` with an existing Discord config.", + ); + } + + return buildResolvedSetup( + webhookUrl, + stringValue(opts.username) ?? stringValue(existingDiscord["username"]), + stringValue(opts.avatarUrl) ?? stringValue(existingDiscord["avatarUrl"]), + stringValue(opts.threadId) ?? stringValue(existingDiscord["threadId"]), + opts.retries ?? existingDiscord["retries"], + opts.retryDelayMs ?? existingDiscord["retryDelayMs"], + resolveDiscordRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"), + opts, + ); +} + +function buildResolvedSetup( + webhookUrl: string, + username: string | undefined, + avatarUrl: string | undefined, + threadId: string | undefined, + retriesValue: unknown, + retryDelayMsValue: unknown, + routingPreset: NotifierRoutingPreset | undefined, + opts: DiscordSetupOptions, +): ResolvedDiscordSetup { + const normalizedWebhookUrl = webhookUrl.trim(); + validateDiscordWebhookUrl(normalizedWebhookUrl); + validateOptionalHttpUrl(avatarUrl, "Avatar URL"); + + return { + webhookUrl: normalizedWebhookUrl, + username: username ?? DEFAULT_USERNAME, + avatarUrl, + threadId, + retries: parseNonNegativeInteger(retriesValue, "Retries", DEFAULT_RETRIES), + retryDelayMs: parseNonNegativeInteger(retryDelayMsValue, "Retry delay", DEFAULT_RETRY_DELAY_MS), + shouldSendTest: opts.test !== false, + routingPreset, + }; +} + +function resolveDiscordRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "Discord") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new DiscordSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function writeDiscordConfig(configPath: string, resolved: ResolvedDiscordSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingDiscord = isRecord(notifiers["discord"]) ? notifiers["discord"] : {}; + const discordConfig: Record = { + ...existingDiscord, + plugin: "discord", + webhookUrl: resolved.webhookUrl, + username: resolved.username, + retries: resolved.retries, + retryDelayMs: resolved.retryDelayMs, + }; + if (resolved.avatarUrl) discordConfig["avatarUrl"] = resolved.avatarUrl; + else delete discordConfig["avatarUrl"]; + if (resolved.threadId) discordConfig["threadId"] = resolved.threadId; + else delete discordConfig["threadId"]; + notifiers["discord"] = discordConfig; + rawConfig["notifiers"] = notifiers; + + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + rawConfig["defaults"] = defaults; + + applyNotifierRoutingPreset(rawConfig, "discord", resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +function webhookUrlStatus(webhookUrl: string | undefined): string { + if (!webhookUrl) return "not configured"; + try { + return `configured (${new URL(webhookUrl).hostname})`; + } catch { + return "configured"; + } +} + +async function printStatus(): Promise { + const context = readConfigContext(); + const existingDiscord = getExistingDiscord(context.rawConfig); + const plugin = stringValue(existingDiscord["plugin"]); + const webhookUrl = stringValue(existingDiscord["webhookUrl"]); + const username = stringValue(existingDiscord["username"]) ?? DEFAULT_USERNAME; + const avatarUrl = stringValue(existingDiscord["avatarUrl"]); + const threadId = stringValue(existingDiscord["threadId"]); + const retries = parseNonNegativeInteger(existingDiscord["retries"], "Retries", DEFAULT_RETRIES); + const retryDelayMs = parseNonNegativeInteger( + existingDiscord["retryDelayMs"], + "Retry delay", + DEFAULT_RETRY_DELAY_MS, + ); + + console.log(chalk.bold("Discord notifier status")); + console.log(` Config: ${context.configPath}`); + console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); + console.log(` Webhook URL: ${webhookUrlStatus(webhookUrl)}`); + console.log(` Username: ${username}`); + console.log(` Avatar URL: ${avatarUrl ?? chalk.dim("not configured")}`); + console.log(` Thread ID: ${threadId ?? chalk.dim("not configured")}`); + console.log(` Retries: ${retries}`); + console.log(` Retry delay: ${retryDelayMs}ms`); + console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "discord").label}`); + + if (plugin !== "discord" || !webhookUrl) return; + + try { + await sendSetupProbe( + buildResolvedSetup(webhookUrl, username, avatarUrl, threadId, retries, retryDelayMs, undefined, { + test: true, + }), + ); + console.log(chalk.green(" Probe: PASS")); + } catch (error) { + console.log( + chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`), + ); + } +} + +export async function runDiscordSetupAction(opts: DiscordSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const force = Boolean(opts.force); + + if (opts.status) { + await printStatus(); + return; + } + + const context = readConfigContext(); + const existingDiscord = getExistingDiscord(context.rawConfig); + const shouldWire = await shouldReplaceConflictingDiscord( + existingDiscord["plugin"], + force, + nonInteractive, + ); + if (!shouldWire) return; + + const resolved = nonInteractive + ? resolveNonInteractiveSetup(opts, existingDiscord) + : await resolveInteractiveSetup(opts, existingDiscord, context.rawConfig); + + if (resolved.shouldSendTest) { + await sendSetupProbe(resolved); + console.log(chalk.green("✓ Discord setup test passed")); + } else { + console.log(chalk.dim("Skipped Discord setup test.")); + } + + writeDiscordConfig(context.configPath, resolved); + console.log(chalk.green(`✓ Config written to ${context.configPath}`)); + + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Discord setup complete!")} AO will send notifications through the configured Discord webhook.\n` + + chalk.dim(" Test it with: ao notify test --to discord --template basic"), + ); + } else { + console.log(chalk.green("\n✓ Discord setup complete.")); + console.log(chalk.dim("Test it with: ao notify test --to discord --template basic")); + } +} diff --git a/packages/cli/src/lib/notifier-routing.ts b/packages/cli/src/lib/notifier-routing.ts new file mode 100644 index 000000000..b2fc09666 --- /dev/null +++ b/packages/cli/src/lib/notifier-routing.ts @@ -0,0 +1,205 @@ +import type { + cancel, + confirm, + intro, + isCancel, + log, + outro, + password, + select, + spinner, + text, +} from "@clack/prompts"; + +export const NOTIFICATION_PRIORITIES = ["urgent", "action", "warning", "info"] as const; + +export type NotificationPriority = (typeof NOTIFICATION_PRIORITIES)[number]; +export type NotifierRoutingPreset = "urgent-only" | "urgent-action" | "all"; +export type NotifierRoutingSelection = NotifierRoutingPreset | "preserve" | "back"; + +export interface ClackPrompts { + cancel: typeof cancel; + confirm: typeof confirm; + intro: typeof intro; + isCancel: typeof isCancel; + log: typeof log; + outro: typeof outro; + password: typeof password; + select: typeof select; + spinner: typeof spinner; + text: typeof text; +} + +const ROUTING_PRESET_PRIORITIES: Record = { + "urgent-only": ["urgent"], + "urgent-action": ["urgent", "action"], + all: ["urgent", "action", "warning", "info"], +}; + +export interface NotifierRoutingState { + preset?: NotifierRoutingPreset; + priorities: NotificationPriority[]; + hasRouting: boolean; + isCustom: boolean; + label: string; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string"); + } + if (typeof value === "string") return [value]; + return []; +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +export function isNotifierRoutingPreset(value: string | undefined): value is NotifierRoutingPreset { + return value === "urgent-only" || value === "urgent-action" || value === "all"; +} + +export function parseNotifierRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined { + return isNotifierRoutingPreset(value) ? value : undefined; +} + +export function notifierRoutingPresetValues(): string { + return "urgent-only | urgent-action | all"; +} + +export function routingLabel(preset: NotifierRoutingPreset): string { + if (preset === "all") return "All priorities"; + if (preset === "urgent-only") return "Urgent only"; + return "Urgent + action"; +} + +export function getNotifierRoutingState( + rawConfig: Record, + notifierName: string, +): NotifierRoutingState { + const routing = isRecord(rawConfig["notificationRouting"]) + ? rawConfig["notificationRouting"] + : {}; + const priorities = NOTIFICATION_PRIORITIES.filter((priority) => + asStringArray(routing[priority]).includes(notifierName), + ); + const hasRouting = priorities.length > 0; + + for (const [preset, presetPriorities] of Object.entries(ROUTING_PRESET_PRIORITIES) as Array< + [NotifierRoutingPreset, readonly NotificationPriority[]] + >) { + if ( + priorities.length === presetPriorities.length && + presetPriorities.every((priority) => priorities.includes(priority)) + ) { + return { + preset, + priorities, + hasRouting, + isCustom: false, + label: routingLabel(preset), + }; + } + } + + return { + priorities, + hasRouting, + isCustom: hasRouting, + label: hasRouting ? priorities.join(" + ") : "not routed", + }; +} + +export function ensureNotifierDefault(rawConfig: Record, notifierName: string): void { + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + defaults["notifiers"] = unique([ + ...asStringArray(defaults["notifiers"]).filter((value) => value !== notifierName), + notifierName, + ]); + rawConfig["defaults"] = defaults; +} + +export function applyNotifierRoutingPreset( + rawConfig: Record, + notifierName: string, + preset: NotifierRoutingPreset | undefined, +): void { + if (!preset) return; + + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + const defaultNotifiers = asStringArray(defaults["notifiers"]); + rawConfig["defaults"] = defaults; + + const notificationRouting = isRecord(rawConfig["notificationRouting"]) + ? rawConfig["notificationRouting"] + : {}; + const selectedPriorities = new Set(ROUTING_PRESET_PRIORITIES[preset]); + + for (const priority of NOTIFICATION_PRIORITIES) { + const base = hasOwn(notificationRouting, priority) + ? asStringArray(notificationRouting[priority]) + : defaultNotifiers; + const next = base.filter((name) => name !== notifierName); + if (selectedPriorities.has(priority)) next.push(notifierName); + notificationRouting[priority] = unique(next); + } + + rawConfig["notificationRouting"] = notificationRouting; +} + +export function resolveRoutingPresetOption( + value: string | undefined, + label: string, +): NotifierRoutingPreset | undefined { + if (value === undefined) return undefined; + const parsed = parseNotifierRoutingPreset(value); + if (parsed) return parsed; + throw new Error(`Invalid ${label} routing preset "${value}". Expected ${notifierRoutingPresetValues()}.`); +} + +export async function promptNotifierRoutingPreset( + clack: ClackPrompts, + rawConfig: Record, + notifierName: string, + notifierLabel: string, + cancel: () => never, +): Promise { + const current = getNotifierRoutingState(rawConfig, notifierName); + const choice = await clack.select({ + message: `Which notifications should ${notifierLabel} receive?`, + initialValue: current.preset ?? "urgent-action", + options: [ + ...(current.hasRouting + ? [ + { + value: "preserve", + label: `Keep current routing (${current.label})`, + hint: "Leave notificationRouting unchanged", + }, + ] + : []), + { value: "urgent-action", label: "Urgent + action", hint: "Recommended" }, + { value: "urgent-only", label: "Urgent only" }, + { value: "all", label: "All priorities" }, + { value: "back", label: "Back", hint: "Return to the previous step" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancel(); + } + + if (choice === "preserve" || choice === "back") return choice; + if (typeof choice === "string" && isNotifierRoutingPreset(choice)) return choice; + return "urgent-action"; +} diff --git a/packages/cli/src/lib/notify-test.ts b/packages/cli/src/lib/notify-test.ts new file mode 100644 index 000000000..47b50f05a --- /dev/null +++ b/packages/cli/src/lib/notify-test.ts @@ -0,0 +1,827 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + buildCIFailureNotificationData, + buildPRStateNotificationData, + buildReactionNotificationData, + buildSessionTransitionNotificationData, + createProjectObserver, + recordNotificationDelivery, + resolveNotifierTarget, + type CICheck, + type EventPriority, + type EventType, + type NotificationDataV3, + type NotificationEventContext, + type Notifier, + type NotifyAction, + type OrchestratorConfig, + type OrchestratorEvent, + type PluginRegistry, +} from "@aoagents/ao-core"; + +export const NOTIFY_TEST_TEMPLATE_NAMES = [ + "basic", + "agent-stuck", + "agent-needs-input", + "agent-exited", + "ci-failing", + "review-changes-requested", + "approved-and-green", + "merge-ready", + "all-complete", + "pr-closed", +] as const; + +export type NotifyTestTemplateName = (typeof NOTIFY_TEST_TEMPLATE_NAMES)[number]; + +const VALID_PRIORITIES = ["urgent", "action", "warning", "info"] as const; + +const VALID_EVENT_TYPES = [ + "session.spawn_started", + "session.spawned", + "session.working", + "session.exited", + "session.killed", + "session.idle", + "session.stuck", + "session.needs_input", + "session.errored", + "pr.created", + "pr.updated", + "pr.merged", + "pr.closed", + "ci.passing", + "ci.failing", + "ci.fix_sent", + "ci.fix_failed", + "review.pending", + "review.approved", + "review.changes_requested", + "review.comments_sent", + "review.comments_unresolved", + "automated_review.found", + "automated_review.fix_sent", + "merge.ready", + "merge.conflicts", + "merge.completed", + "reaction.triggered", + "reaction.escalated", + "summary.all_complete", +] as const satisfies EventType[]; + +interface NotifyTemplate { + type: EventType; + priority: EventPriority; + sessionId: string; + projectId: string; + message: string; + data: NotificationDataV3; +} + +const DEMO_PR_URL = "https://github.com/ComposioHQ/agent-orchestrator/pull/1579"; + +const DEMO_PR_CONTEXT: NotificationEventContext = { + pr: { + number: 1579, + url: DEMO_PR_URL, + title: "Normalize AO notifier payloads", + branch: "ao/demo-notifier-harness", + baseBranch: "main", + owner: "ComposioHQ", + repo: "agent-orchestrator", + isDraft: false, + }, + issueId: "AO-1579", + issueTitle: "Make AO notification payloads API-grade", + summary: "Normalize AO notifier payloads", + branch: "ao/demo-notifier-harness", +}; + +const DEMO_SYSTEM_CONTEXT: NotificationEventContext = { + pr: null, + issueId: null, + issueTitle: null, + summary: "AO notification delivery smoke test", + branch: null, +}; + +const DEMO_FAILED_CHECKS: CICheck[] = [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: `${DEMO_PR_URL}/checks?check_run_id=101`, + }, + { + name: "unit-tests", + status: "failed", + conclusion: "FAILURE", + url: `${DEMO_PR_URL}/checks?check_run_id=102`, + }, +]; + +const DEMO_TEMPLATES: Record = { + basic: { + type: "summary.all_complete", + priority: "info", + sessionId: "notify-demo", + projectId: "demo", + message: "Test notification from ao notify test", + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId: "notify-demo", + projectId: "demo", + context: DEMO_SYSTEM_CONTEXT, + reactionKey: "all-complete", + action: "notify", + }), + }, + "agent-stuck": { + type: "session.stuck", + priority: "urgent", + sessionId: "demo-agent-7", + projectId: "demo", + message: "Agent demo-agent-7 appears stuck after repeated inactivity probes", + data: buildSessionTransitionNotificationData({ + eventType: "session.stuck", + sessionId: "demo-agent-7", + projectId: "demo", + context: DEMO_PR_CONTEXT, + oldStatus: "working", + newStatus: "stuck", + }), + }, + "agent-needs-input": { + type: "session.needs_input", + priority: "action", + sessionId: "demo-agent-12", + projectId: "demo", + message: "Agent demo-agent-12 needs input before it can continue", + data: buildSessionTransitionNotificationData({ + eventType: "session.needs_input", + sessionId: "demo-agent-12", + projectId: "demo", + context: DEMO_PR_CONTEXT, + oldStatus: "working", + newStatus: "needs_input", + }), + }, + "agent-exited": { + type: "session.killed", + priority: "urgent", + sessionId: "demo-agent-4", + projectId: "demo", + message: "Agent demo-agent-4 exited before completing its task", + data: buildSessionTransitionNotificationData({ + eventType: "session.killed", + sessionId: "demo-agent-4", + projectId: "demo", + context: DEMO_PR_CONTEXT, + oldStatus: "working", + newStatus: "killed", + }), + }, + "ci-failing": { + type: "ci.failing", + priority: "action", + sessionId: "demo-agent-19", + projectId: "demo", + message: "CI is failing on PR #1579", + data: buildCIFailureNotificationData({ + sessionId: "demo-agent-19", + projectId: "demo", + context: DEMO_PR_CONTEXT, + failedChecks: DEMO_FAILED_CHECKS, + }), + }, + "review-changes-requested": { + type: "review.changes_requested", + priority: "action", + sessionId: "demo-agent-21", + projectId: "demo", + message: "Review changes were requested on PR #1579", + data: (() => { + const data = buildSessionTransitionNotificationData({ + eventType: "review.changes_requested", + sessionId: "demo-agent-21", + projectId: "demo", + context: DEMO_PR_CONTEXT, + oldStatus: "review_pending", + newStatus: "changes_requested", + }); + data.review = { + ...(data.review ?? {}), + decision: "changes_requested", + unresolvedThreads: 3, + url: `${DEMO_PR_URL}#pullrequestreview-1`, + }; + return data; + })(), + }, + "approved-and-green": { + type: "reaction.triggered", + priority: "info", + sessionId: "demo-agent-23", + projectId: "demo", + message: "PR #1579 is approved and CI is green", + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId: "demo-agent-23", + projectId: "demo", + context: DEMO_PR_CONTEXT, + reactionKey: "approved-and-green", + action: "notify", + enrichment: { + state: "open", + ciStatus: "passing", + reviewDecision: "approved", + mergeable: true, + title: "Normalize AO notifier payloads", + hasConflicts: false, + isBehind: false, + }, + }), + }, + "merge-ready": { + type: "merge.ready", + priority: "action", + sessionId: "demo-agent-29", + projectId: "demo", + message: "PR #1579 is ready to merge", + data: buildSessionTransitionNotificationData({ + eventType: "merge.ready", + sessionId: "demo-agent-29", + projectId: "demo", + context: DEMO_PR_CONTEXT, + oldStatus: "approved", + newStatus: "mergeable", + enrichment: { + state: "open", + ciStatus: "passing", + reviewDecision: "approved", + mergeable: true, + title: "Normalize AO notifier payloads", + hasConflicts: false, + isBehind: false, + }, + }), + }, + "all-complete": { + type: "summary.all_complete", + priority: "info", + sessionId: "demo-orchestrator", + projectId: "demo", + message: "All demo sessions completed successfully", + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId: "demo-orchestrator", + projectId: "demo", + context: DEMO_SYSTEM_CONTEXT, + reactionKey: "all-complete", + action: "notify", + }), + }, + "pr-closed": { + type: "pr.closed", + priority: "warning", + sessionId: "demo-agent-31", + projectId: "demo", + message: "PR #1579 was closed without merge", + data: buildPRStateNotificationData({ + eventType: "pr.closed", + sessionId: "demo-agent-31", + projectId: "demo", + context: DEMO_PR_CONTEXT, + oldPRState: "open", + newPRState: "closed", + }), + }, +}; + +export const NOTIFY_TEST_ACTIONS: NotifyAction[] = [ + { + label: "Open dashboard", + url: "http://localhost:3000", + }, + { + label: "View PR", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + }, + { + label: "Acknowledge", + callbackEndpoint: "http://localhost:3000/api/notifications/demo/ack", + }, +]; + +export interface NotifyTestRequest { + templateName?: string; + to?: string[]; + all?: boolean; + route?: string; + actions?: boolean; + message?: string; + sessionId?: string; + projectId?: string; + priority?: string; + type?: string; + data?: Record; + dryRun?: boolean; +} + +export interface NotifyTestTarget { + reference: string; + pluginName: string; +} + +export type NotifyDeliveryStatus = "sent" | "dry_run" | "failed" | "unresolved"; + +export interface NotifyDeliveryResult { + reference: string; + pluginName: string; + status: NotifyDeliveryStatus; + method: "notify" | "notifyWithActions" | null; + warning?: string; + error?: string; +} + +export interface NotifyTestResult { + ok: boolean; + dryRun: boolean; + templateName: NotifyTestTemplateName; + event: OrchestratorEvent; + actions: NotifyAction[]; + targets: NotifyTestTarget[]; + deliveries: NotifyDeliveryResult[]; + warnings: string[]; + errors: string[]; +} + +export interface NotifySinkRequest { + method: string; + url: string; + headers: Record; + body: string; + json: unknown; +} + +export interface NotifySinkServer { + port: number; + url: string; + requests: NotifySinkRequest[]; + waitForRequest(timeoutMs?: number): Promise; + close(): Promise; +} + +export class NotifyTestError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "NotifyTestError"; + this.code = code; + } +} + +function assertTemplateName(name: string | undefined): NotifyTestTemplateName { + const candidate = name ?? "basic"; + if (isTemplateName(candidate)) return candidate; + throw new NotifyTestError( + "invalid_template", + `Unknown template "${candidate}". Expected one of: ${NOTIFY_TEST_TEMPLATE_NAMES.join(", ")}`, + ); +} + +function isTemplateName(value: string): value is NotifyTestTemplateName { + return NOTIFY_TEST_TEMPLATE_NAMES.includes(value as NotifyTestTemplateName); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function syncNotificationSubject( + data: Record, + sessionId: string, + projectId: string, +): Record { + if (data.schemaVersion !== 3 || !isRecord(data.subject)) return data; + + return { + ...data, + subject: { + ...data.subject, + session: { id: sessionId, projectId }, + }, + }; +} + +function assertPriority(priority: string, source: string): EventPriority { + if ((VALID_PRIORITIES as readonly string[]).includes(priority)) { + return priority as EventPriority; + } + throw new NotifyTestError( + "invalid_priority", + `Invalid ${source} "${priority}". Expected one of: ${VALID_PRIORITIES.join(", ")}`, + ); +} + +function assertEventType(type: string): EventType { + if ((VALID_EVENT_TYPES as readonly string[]).includes(type)) { + return type as EventType; + } + throw new NotifyTestError( + "invalid_event_type", + `Invalid event type "${type}". Expected one of: ${VALID_EVENT_TYPES.join(", ")}`, + ); +} + +function uniqueRefs(refs: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const ref of refs) { + const normalized = ref.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function refsFromConfiguredAndDefaults(config: OrchestratorConfig): string[] { + return uniqueRefs([ + ...Object.keys(config.notifiers ?? {}), + ...(config.defaults?.notifiers ?? []), + ]); +} + +function refsFromAllKnownSources(config: OrchestratorConfig): string[] { + return uniqueRefs([ + ...Object.keys(config.notifiers ?? {}), + ...(config.defaults?.notifiers ?? []), + ...Object.values(config.notificationRouting ?? {}).flat(), + ]); +} + +function refsFromRoute(config: OrchestratorConfig, priority: EventPriority): string[] { + return uniqueRefs(config.notificationRouting?.[priority] ?? config.defaults?.notifiers ?? []); +} + +export function createNotifyTestEvent(request: NotifyTestRequest = {}): { + templateName: NotifyTestTemplateName; + event: OrchestratorEvent; +} { + const templateName = assertTemplateName(request.templateName); + const template = DEMO_TEMPLATES[templateName]; + const priority = request.priority + ? assertPriority(request.priority, "priority") + : template.priority; + const type = request.type ? assertEventType(request.type) : template.type; + const sessionId = request.sessionId ?? template.sessionId; + const projectId = request.projectId ?? template.projectId; + const data = syncNotificationSubject( + { + ...template.data, + ...(request.data ?? {}), + }, + sessionId, + projectId, + ); + + return { + templateName, + event: { + id: `notify-test-${Date.now()}`, + type, + priority, + sessionId, + projectId, + timestamp: new Date(), + message: request.message ?? template.message, + data, + }, + }; +} + +export function resolveNotifyTestTargets( + config: OrchestratorConfig, + eventPriority: EventPriority, + request: NotifyTestRequest = {}, +): NotifyTestTarget[] { + const refs = (() => { + if (request.to && request.to.length > 0) { + return uniqueRefs(request.to); + } + if (request.all) { + return refsFromAllKnownSources(config); + } + if (request.route) { + return refsFromRoute(config, assertPriority(request.route, "route")); + } + + const routedRefs = refsFromRoute(config, eventPriority); + return routedRefs.length > 0 ? routedRefs : refsFromConfiguredAndDefaults(config); + })(); + + return refs.map((ref) => { + const target = resolveNotifierTarget(config, ref); + return { + reference: target.reference, + pluginName: target.pluginName, + }; + }); +} + +export async function runNotifyTest( + config: OrchestratorConfig, + registry: PluginRegistry, + request: NotifyTestRequest = {}, +): Promise { + const { templateName, event } = createNotifyTestEvent(request); + const targets = resolveNotifyTestTargets(config, event.priority, request); + const actions = request.actions ? [...NOTIFY_TEST_ACTIONS] : []; + const warnings: string[] = []; + const errors: string[] = []; + const deliveries: NotifyDeliveryResult[] = []; + const observer = request.dryRun ? null : createProjectObserver(config, "notify-test"); + + if (targets.length === 0) { + errors.push( + "No notifier targets resolved. Configure notifiers or pass --to, --all, or --sink.", + ); + return { + ok: false, + dryRun: Boolean(request.dryRun), + templateName, + event, + actions, + targets, + deliveries, + warnings, + errors, + }; + } + + for (const target of targets) { + const notifier = + registry.get("notifier", target.reference) ?? + registry.get("notifier", target.pluginName); + + if (!notifier) { + const error = `${target.reference}: notifier plugin "${target.pluginName}" is not loaded`; + errors.push(error); + if (observer) { + recordNotificationDelivery({ + observer, + event, + target, + outcome: "failure", + method: "notify", + reason: "notifier target not found", + failureKind: "target_missing", + recordActivityEvent: true, + }); + } + deliveries.push({ + reference: target.reference, + pluginName: target.pluginName, + status: "unresolved", + method: null, + error, + }); + continue; + } + + if (request.dryRun) { + deliveries.push({ + reference: target.reference, + pluginName: target.pluginName, + status: "dry_run", + method: actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify", + }); + continue; + } + + try { + const method = + actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify"; + if (actions.length > 0 && notifier.notifyWithActions) { + await notifier.notifyWithActions(event, actions); + deliveries.push({ + reference: target.reference, + pluginName: target.pluginName, + status: "sent", + method: "notifyWithActions", + }); + } else { + const warning = + actions.length > 0 + ? `${target.reference}: notifyWithActions() is unavailable; sent with notify()` + : undefined; + if (warning) warnings.push(warning); + + await notifier.notify(event); + deliveries.push({ + reference: target.reference, + pluginName: target.pluginName, + status: "sent", + method: "notify", + warning, + }); + } + if (observer) { + recordNotificationDelivery({ + observer, + event, + target, + outcome: "success", + method, + }); + } + } catch (err) { + const error = `${target.reference}: ${err instanceof Error ? err.message : String(err)}`; + const method = + actions.length > 0 && notifier.notifyWithActions ? "notifyWithActions" : "notify"; + errors.push(error); + if (observer) { + recordNotificationDelivery({ + observer, + event, + target, + outcome: "failure", + method, + reason: err instanceof Error ? err.message : String(err), + failureKind: "delivery_failed", + recordActivityEvent: true, + }); + } + deliveries.push({ + reference: target.reference, + pluginName: target.pluginName, + status: "failed", + method, + error, + }); + } + } + + return { + ok: errors.length === 0, + dryRun: Boolean(request.dryRun), + templateName, + event, + actions, + targets, + deliveries, + warnings, + errors, + }; +} + +export function parseNotifyDataJson( + input: string | undefined, +): Record | undefined { + if (!input) return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new NotifyTestError("invalid_json", `Invalid --data JSON: ${message}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new NotifyTestError("invalid_json", "--data must be a JSON object"); + } + + return parsed as Record; +} + +export function parseNotifyRefs(input: string | undefined): string[] | undefined { + if (!input) return undefined; + return uniqueRefs(input.split(",")); +} + +export function parseSinkPort(input: true | string | undefined): number | undefined { + if (input === undefined) return undefined; + if (input === true) return 0; + + const port = Number(input); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new NotifyTestError("invalid_sink_port", `Invalid --sink port "${input}"`); + } + return port; +} + +export function addSinkNotifierConfig( + config: OrchestratorConfig, + sinkUrl: string, +): OrchestratorConfig { + return { + ...config, + defaults: { + ...config.defaults, + notifiers: uniqueRefs(["sink", ...(config.defaults?.notifiers ?? [])]), + }, + notifiers: { + ...(config.notifiers ?? {}), + sink: { + plugin: "webhook", + url: sinkUrl, + retries: 0, + }, + }, + }; +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +function respond(res: ServerResponse, statusCode: number, body = ""): void { + res.statusCode = statusCode; + if (body) { + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + } + res.end(body); +} + +export async function startNotifySink(port = 0): Promise { + const requests: NotifySinkRequest[] = []; + const waiters: Array<(request: NotifySinkRequest | null) => void> = []; + + const server = createServer(async (req, res) => { + if (req.method !== "POST") { + respond(res, 405, "method not allowed"); + return; + } + + const body = await readRequestBody(req); + const json = (() => { + try { + return body ? (JSON.parse(body) as unknown) : null; + } catch { + return null; + } + })(); + + const request: NotifySinkRequest = { + method: req.method, + url: req.url ?? "/", + headers: req.headers, + body, + json, + }; + + requests.push(request); + const pending = waiters.splice(0); + for (const waiter of pending) waiter(request); + respond(res, 204); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address() as AddressInfo; + + return { + port: address.port, + url: `http://127.0.0.1:${address.port}`, + requests, + waitForRequest(timeoutMs = 1000): Promise { + if (requests[0]) return Promise.resolve(requests[0]); + return new Promise((resolve) => { + const waiter = (request: NotifySinkRequest | null) => { + clearTimeout(timer); + resolve(request); + }; + const timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + resolve(null); + }, timeoutMs); + waiters.push(waiter); + }); + }, + close(): Promise { + return new Promise((resolve, reject) => { + closeServer(server, (err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +function closeServer(server: Server, callback: (err?: Error) => void): void { + server.close((err) => callback(err ?? undefined)); +} diff --git a/packages/cli/src/lib/openclaw-setup.ts b/packages/cli/src/lib/openclaw-setup.ts new file mode 100644 index 000000000..700fc8c7a --- /dev/null +++ b/packages/cli/src/lib/openclaw-setup.ts @@ -0,0 +1,889 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + ensureNotifierDefault, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + routingLabel, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; +import { + DEFAULT_OPENCLAW_URL, + HOOKS_PATH, + detectOpenClawInstallation, + probeGateway, + validateToken, +} from "./openclaw-probe.js"; + +export type OpenClawRoutingPreset = NotifierRoutingPreset; + +export interface OpenClawSetupOptions { + url?: string; + token?: string; + openclawConfigPath?: string; + nonInteractive?: boolean; + routingPreset?: OpenClawRoutingPreset; + refresh?: boolean; + status?: boolean; + test?: boolean; + force?: boolean; +} + +interface ConfigContext { + configPath: string; + rawConfig: Record; +} + +interface TokenInfo { + value: string; + source: "cli" | "env" | "yaml" | "openclaw-config" | "manual"; + configPath: string; +} + +interface ResolvedOpenClawSetup { + url: string; + token: string; + openclawConfigPath: string; + routingPreset?: OpenClawRoutingPreset; + shouldSendTest: boolean; + tokenSource: TokenInfo["source"]; +} + +const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), ".openclaw", "openclaw.json"); +const DISPLAY_OPENCLAW_CONFIG_PATH = "~/.openclaw/openclaw.json"; + +export class OpenClawSetupError extends Error { + constructor( + message: string, + public readonly exitCode: number = 1, + ) { + super(message); + this.name = "OpenClawSetupError"; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeOpenClawHooksUrl(url: string): string { + const normalized = url.trim().replace(/\/+$/, ""); + return normalized.endsWith(HOOKS_PATH) ? normalized : `${normalized}${HOOKS_PATH}`; +} + +function expandHomePath(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +function displayOpenClawConfigPath(path: string): string { + const expanded = expandHomePath(path); + return expanded === DEFAULT_OPENCLAW_CONFIG_PATH ? DISPLAY_OPENCLAW_CONFIG_PATH : path; +} + +function validateOpenClawUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new OpenClawSetupError("OpenClaw webhook URL is invalid."); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new OpenClawSetupError("OpenClaw webhook URL must start with http:// or https://."); + } +} + +function readConfigContext(): ConfigContext { + const configPath = findConfigFile() ?? undefined; + if (!configPath) { + throw new OpenClawSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingOpenClaw(rawConfig: Record): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = notifiers["openclaw"]; + return isRecord(existing) ? existing : {}; +} + +function getOpenClawJsonPath(): string { + return DEFAULT_OPENCLAW_CONFIG_PATH; +} + +function readOpenClawJson(configPath: string = DEFAULT_OPENCLAW_CONFIG_PATH): { + path: string; + exists: boolean; + config: Record; + token?: string; +} { + const path = expandHomePath(configPath); + try { + if (!existsSync(path)) return { path, exists: false, config: {} }; + const config = JSON.parse(readFileSync(path, "utf-8")) as Record; + const hooks = isRecord(config["hooks"]) ? config["hooks"] : {}; + return { + path, + exists: true, + config, + token: stringValue(hooks["token"]), + }; + } catch { + return { path, exists: existsSync(path), config: {} }; + } +} + +function getConfiguredOpenClawConfigPath( + opts: OpenClawSetupOptions, + existingOpenClaw: Record, +): string { + return ( + stringValue(opts.openclawConfigPath) ?? + stringValue(existingOpenClaw["openclawConfigPath"]) ?? + stringValue(existingOpenClaw["configPath"]) ?? + getOpenClawJsonPath() + ); +} + +function resolveConfiguredToken( + existingOpenClaw: Record, + openclawConfigPath: string, +): TokenInfo | undefined { + const openclawJson = readOpenClawJson(openclawConfigPath); + if (openclawJson.token) { + return { + value: openclawJson.token, + source: "openclaw-config", + configPath: openclawJson.path, + }; + } + + const rawYamlToken = stringValue(existingOpenClaw["token"]); + if (rawYamlToken) { + const envVarMatch = rawYamlToken.match(/^\$\{([^}]+)\}$/); + if (envVarMatch) { + const envValue = process.env[envVarMatch[1]]; + if (envValue) return { value: envValue, source: "env", configPath: openclawJson.path }; + } else { + return { value: rawYamlToken, source: "yaml", configPath: openclawJson.path }; + } + } + + const envToken = process.env["OPENCLAW_HOOKS_TOKEN"]; + if (envToken) return { value: envToken, source: "env", configPath: openclawJson.path }; + + return undefined; +} + +async function shouldReplaceConflictingOpenClaw( + existingPlugin: unknown, + force: boolean, + nonInteractive: boolean, +): Promise { + if (existingPlugin === undefined || existingPlugin === "openclaw" || force) return true; + if (nonInteractive) { + throw new OpenClawSetupError( + `notifiers.openclaw already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`, + ); + } + + const clack = await import("@clack/prompts"); + const replace = await clack.confirm({ + message: `notifiers.openclaw already uses plugin "${String(existingPlugin)}". Replace it?`, + initialValue: false, + }); + + if (clack.isCancel(replace) || !replace) { + console.log(chalk.dim("Keeping existing OpenClaw notifier config.")); + return false; + } + + return true; +} + +function cancelSetup(clack: ClackPrompts): never { + clack.cancel("Setup cancelled."); + throw new OpenClawSetupError("Setup cancelled.", 0); +} + +function printOpenClawStartInstructions(): void { + console.log(""); + console.log(chalk.bold("Start or install OpenClaw")); + console.log(" 1. Start your local OpenClaw gateway."); + console.log(" 2. Confirm the gateway URL. The default is http://127.0.0.1:18789."); + console.log(" 3. Paste the hooks URL here. AO will normalize it to /hooks/agent."); + console.log(chalk.dim("If OpenClaw runs elsewhere, use that machine's gateway URL.")); + console.log(""); +} + +function printOpenClawTokenInstructions(openclawConfigPath: string): void { + const displayPath = displayOpenClawConfigPath(openclawConfigPath); + console.log(""); + console.log(chalk.bold("Configure the OpenClaw hooks token")); + console.log(` 1. Open your OpenClaw config: ${displayPath}`); + console.log(" 2. In OpenClaw's webhook/hooks settings, create or copy the hooks token."); + console.log(" 3. Put that token in hooks.token and make sure hooks are enabled:"); + console.log(""); + console.log( + chalk.cyan(`{ + "hooks": { + "enabled": true, + "token": "", + "allowRequestSessionKey": true, + "allowedSessionKeyPrefixes": ["hook:"] + } +}`), + ); + console.log(""); + console.log( + chalk.dim( + "OpenClaw requires this shared secret for POST /hooks/agent. AO reads it from the OpenClaw config and does not generate or store it in your shell profile.", + ), + ); + console.log(chalk.dim("Restart OpenClaw after changing the config.")); + console.log(""); +} + +async function promptOpenClawUrl( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const urlInput = await clack.text({ + message: "OpenClaw webhook URL:", + placeholder: `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`, + initialValue, + validate: (value) => { + if (!value) return "OpenClaw webhook URL is required"; + try { + validateOpenClawUrl(normalizeOpenClawHooksUrl(String(value))); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(urlInput)) { + cancelSetup(clack); + } + + return normalizeOpenClawHooksUrl(String(urlInput)); +} + +async function promptAfterOpenClawInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printOpenClawStartInstructions(); + + while (true) { + const next = await clack.select({ + message: "What do you want to do next?", + options: [ + { + value: "enter-url", + label: "Enter OpenClaw URL", + hint: "Paste the local or remote gateway URL", + }, + { + value: "back", + label: "Back", + hint: "Return to the previous menu", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelSetup(clack); + } + + if (next === "back") return "back"; + if (next === "enter-url") return promptOpenClawUrl(clack, initialValue); + } +} + +async function resolveInteractiveUrl( + clack: ClackPrompts, + opts: OpenClawSetupOptions, + existingUrl: string | undefined, +): Promise { + const providedUrl = stringValue(opts.url); + if (providedUrl) { + const normalized = normalizeOpenClawHooksUrl(providedUrl); + validateOpenClawUrl(normalized); + return normalized; + } + + const defaultHooksUrl = `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`; + let detectedUrl: string | undefined; + const spin = clack.spinner(); + spin.start("Detecting OpenClaw gateway on localhost..."); + const probe = await probeGateway(DEFAULT_OPENCLAW_URL); + if (probe.reachable) { + detectedUrl = defaultHooksUrl; + spin.stop(`Found OpenClaw gateway at ${DEFAULT_OPENCLAW_URL}`); + } else { + spin.stop("No OpenClaw gateway detected on localhost"); + } + + while (true) { + const source = existingUrl + ? await clack.select({ + message: "OpenClaw notifier is already configured. What do you want to do?", + options: [ + { + value: "use-existing", + label: "Use existing gateway URL", + hint: existingUrl, + }, + { + value: "change-url", + label: "Change gateway URL", + hint: "Paste a different OpenClaw gateway URL", + }, + { + value: "use-local", + label: "Use local default URL", + hint: defaultHooksUrl, + }, + { + value: "need-openclaw", + label: "Show OpenClaw setup steps", + hint: "AO will print the local gateway requirements", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }) + : await clack.select({ + message: "How do you want to point AO at OpenClaw?", + options: [ + { + value: "use-local", + label: probe.reachable ? "Use detected local gateway" : "Use local default URL", + hint: detectedUrl ?? defaultHooksUrl, + }, + { + value: "change-url", + label: "Enter a different URL", + hint: "Use this when OpenClaw runs elsewhere", + }, + { + value: "need-openclaw", + label: "Show OpenClaw setup steps", + hint: "AO will print the local gateway requirements", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(source) || source === "cancel") { + cancelSetup(clack); + } + + if (source === "use-existing" && existingUrl) return normalizeOpenClawHooksUrl(existingUrl); + if (source === "use-local") return detectedUrl ?? defaultHooksUrl; + if (source === "change-url") return promptOpenClawUrl(clack, existingUrl ?? detectedUrl); + if (source === "need-openclaw") { + const result = await promptAfterOpenClawInstructions(clack, existingUrl ?? detectedUrl); + if (result === "back") continue; + return result; + } + } +} + +async function resolveInteractiveToken( + clack: ClackPrompts, + opts: OpenClawSetupOptions, + existingOpenClaw: Record, + initialOpenClawConfigPath: string, +): Promise { + const providedToken = stringValue(opts.token); + if (providedToken) { + return { value: providedToken, source: "cli", configPath: expandHomePath(initialOpenClawConfigPath) }; + } + + let openclawConfigPath = initialOpenClawConfigPath; + while (true) { + const existingToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath); + const options = [ + ...(existingToken + ? [ + { + value: "use-existing", + label: `Use existing token from ${existingToken.source}`, + hint: + existingToken.source === "openclaw-config" + ? displayOpenClawConfigPath(existingToken.configPath) + : "Legacy fallback; AO will not write shell exports", + }, + ] + : []), + { + value: "check-config", + label: existingToken ? "Check OpenClaw config again" : "I added hooks.token to OpenClaw config", + hint: displayOpenClawConfigPath(openclawConfigPath), + }, + { + value: "show-steps", + label: "Show where to configure the token", + hint: "Print OpenClaw-side config steps", + }, + { + value: "manual", + label: "Enter token manually", + hint: "For remote OpenClaw only; AO stores it in agent-orchestrator.yaml", + }, + { + value: "config-path", + label: "Use a different OpenClaw config path", + hint: "Read hooks.token from another local OpenClaw config", + }, + { + value: "back", + label: "Back", + hint: "Return to gateway URL", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ]; + + const choice = await clack.select({ + message: "How should AO configure the OpenClaw hooks token?", + options, + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelSetup(clack); + } + + if (choice === "back") return "back"; + if (choice === "use-existing" && existingToken) return existingToken; + if (choice === "check-config") { + const refreshedToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath); + if (refreshedToken) return refreshedToken; + clack.log.warn( + `No hooks.token found in ${displayOpenClawConfigPath(openclawConfigPath)} yet.`, + ); + continue; + } + if (choice === "show-steps") { + printOpenClawTokenInstructions(openclawConfigPath); + continue; + } + if (choice === "config-path") { + const pathInput = await clack.text({ + message: "OpenClaw config path:", + placeholder: DISPLAY_OPENCLAW_CONFIG_PATH, + initialValue: displayOpenClawConfigPath(openclawConfigPath), + validate: (value) => (!value ? "OpenClaw config path is required" : undefined), + }); + if (clack.isCancel(pathInput)) { + cancelSetup(clack); + } + openclawConfigPath = String(pathInput); + continue; + } + if (choice === "manual") { + const input = await clack.password({ + message: "OpenClaw hooks token:", + validate: (value) => (!value ? "Token is required" : undefined), + }); + if (clack.isCancel(input)) { + cancelSetup(clack); + } + return { value: String(input), source: "manual", configPath: expandHomePath(openclawConfigPath) }; + } + } +} + +async function resolveInteractiveRoutingPreset( + clack: ClackPrompts, + opts: OpenClawSetupOptions, + rawConfig: Record, +): Promise { + const optionPreset = resolveOpenClawRoutingPreset(opts.routingPreset); + if (optionPreset) return optionPreset; + + const selection = await promptNotifierRoutingPreset( + clack, + rawConfig, + "openclaw", + "OpenClaw", + () => cancelSetup(clack), + ); + if (selection === "preserve") return undefined; + return selection; +} + +function resolveOpenClawRoutingPreset(value: string | undefined): OpenClawRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "OpenClaw") as OpenClawRoutingPreset | undefined; + } catch (error) { + throw new OpenClawSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function printReview(resolved: ResolvedOpenClawSetup): void { + console.log(""); + console.log(chalk.bold("OpenClaw setup review")); + console.log(` Webhook URL: ${resolved.url}`); + console.log(` Token: configured from ${resolved.tokenSource}`); + console.log(` OpenClaw config: ${displayOpenClawConfigPath(resolved.openclawConfigPath)}`); + console.log(` Routing: ${resolved.routingPreset ? routingLabel(resolved.routingPreset) : "unchanged"}`); + console.log(` Setup probe: ${resolved.shouldSendTest ? "enabled" : "skipped"}`); + console.log(""); +} + +async function promptInteractiveReview( + clack: ClackPrompts, + resolved: ResolvedOpenClawSetup, +): Promise<"save" | "url" | "token" | "routing"> { + printReview(resolved); + const choice = await clack.select({ + message: "Save this OpenClaw setup?", + options: [ + { value: "save", label: "Save setup" }, + { value: "url", label: "Change gateway URL" }, + { value: "token", label: "Change token" }, + { value: "routing", label: "Change routing" }, + { value: "cancel", label: "Cancel setup", hint: "Do not change config" }, + ], + }); + + if (clack.isCancel(choice) || choice === "cancel") { + cancelSetup(clack); + } + + return choice as "save" | "url" | "token" | "routing"; +} + +async function resolveInteractiveSetup( + opts: OpenClawSetupOptions, + existingOpenClaw: Record, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + const existingUrl = stringValue(existingOpenClaw["url"]); + const initialOpenClawConfigPath = getConfiguredOpenClawConfigPath(opts, existingOpenClaw); + + clack.intro(chalk.bgCyan(chalk.black(" ao setup openclaw "))); + + let url: string | undefined; + let tokenInfo: TokenInfo | undefined; + let routingPreset: OpenClawRoutingPreset | undefined; + let step: "url" | "token" | "routing" | "review" = "url"; + + while (true) { + if (step === "url") { + url = await resolveInteractiveUrl(clack, opts, existingUrl); + step = "token"; + } + + if (step === "token") { + const selectedTokenInfo = await resolveInteractiveToken( + clack, + opts, + existingOpenClaw, + tokenInfo?.configPath ?? initialOpenClawConfigPath, + ); + if (selectedTokenInfo === "back") { + step = "url"; + continue; + } + tokenInfo = selectedTokenInfo; + step = "routing"; + } + + if (step === "routing") { + const selectedRoutingPreset = await resolveInteractiveRoutingPreset( + clack, + opts, + rawConfig, + ); + if (selectedRoutingPreset === "back") { + step = "token"; + continue; + } + routingPreset = selectedRoutingPreset; + step = "review"; + } + + if (step === "review") { + const resolved = { + url: url ?? `${DEFAULT_OPENCLAW_URL}${HOOKS_PATH}`, + token: tokenInfo?.value ?? "", + openclawConfigPath: tokenInfo?.configPath ?? expandHomePath(initialOpenClawConfigPath), + routingPreset, + shouldSendTest: opts.test !== false, + tokenSource: tokenInfo?.source ?? "manual", + } satisfies ResolvedOpenClawSetup; + + const next = await promptInteractiveReview(clack, resolved); + if (next === "save") return resolved; + step = next; + } + } +} + +async function resolveNonInteractiveSetup( + opts: OpenClawSetupOptions, + existingOpenClaw: Record, + _rawConfig: Record, +): Promise { + let rawUrl = + stringValue(opts.url) ?? + process.env["OPENCLAW_GATEWAY_URL"] ?? + (opts.refresh ? stringValue(existingOpenClaw["url"]) : undefined); + + if (!rawUrl) { + const installation = await detectOpenClawInstallation(); + if (installation.state === "running") { + rawUrl = `${installation.gatewayUrl}${HOOKS_PATH}`; + console.log(chalk.dim(`Auto-detected OpenClaw gateway at ${installation.gatewayUrl}`)); + } else { + throw new OpenClawSetupError( + "Error: OpenClaw gateway not reachable and no --url provided.\n" + + " Start OpenClaw first, or pass --url explicitly:\n" + + " Example: ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --openclaw-config-path ~/.openclaw/openclaw.json --non-interactive", + ); + } + } + + const url = normalizeOpenClawHooksUrl(rawUrl); + validateOpenClawUrl(url); + + const openclawConfigPath = getConfiguredOpenClawConfigPath(opts, existingOpenClaw); + const configuredToken = resolveConfiguredToken(existingOpenClaw, openclawConfigPath); + const tokenInfo = + stringValue(opts.token) !== undefined + ? ({ + value: stringValue(opts.token) as string, + source: "cli", + configPath: expandHomePath(openclawConfigPath), + } satisfies TokenInfo) + : configuredToken; + + if (!tokenInfo) { + throw new OpenClawSetupError( + `No OpenClaw hooks token found in ${displayOpenClawConfigPath(openclawConfigPath)}.\n` + + " Generate or copy the hooks token from OpenClaw, put it in hooks.token, then rerun setup.\n" + + ` Config example: ${displayOpenClawConfigPath(openclawConfigPath)} -> hooks.token\n` + + " For remote OpenClaw only, pass --token explicitly.", + ); + } + + console.log(chalk.dim("Skipping setup probe in non-interactive mode. Run `ao setup openclaw --status` to verify.")); + + const routingPreset = resolveOpenClawRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "urgent-action"); + + return { + url, + token: tokenInfo.value, + openclawConfigPath: tokenInfo.configPath, + routingPreset, + shouldSendTest: opts.test !== false, + tokenSource: tokenInfo.source, + }; +} + +function writeOpenClawConfig( + configPath: string, + resolved: ResolvedOpenClawSetup, + nonInteractive: boolean, +): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const openclawConfig: Record = { + plugin: "openclaw", + url: resolved.url, + openclawConfigPath: displayOpenClawConfigPath(resolved.openclawConfigPath), + retries: 3, + retryDelayMs: 1000, + wakeMode: "now", + }; + if (resolved.tokenSource === "cli" || resolved.tokenSource === "manual" || resolved.tokenSource === "yaml") { + openclawConfig["token"] = resolved.token; + } else if (resolved.tokenSource === "env") { + openclawConfig["token"] = "$" + "{OPENCLAW_HOOKS_TOKEN}"; + } + notifiers["openclaw"] = openclawConfig; + rawConfig["notifiers"] = notifiers; + + if (resolved.routingPreset) { + ensureNotifierDefault(rawConfig, "openclaw"); + } + applyNotifierRoutingPreset(rawConfig, "openclaw", resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + + writeFileSync(configPath, doc.toString({ indent: 2 })); + + if (nonInteractive) { + console.log(chalk.green(`✓ Config written to ${configPath}`)); + } +} + +function printOpenClawInstructions(nonInteractive: boolean, resolved: ResolvedOpenClawSetup): void { + const tokenLocation = + resolved.tokenSource === "openclaw-config" + ? displayOpenClawConfigPath(resolved.openclawConfigPath) + : resolved.tokenSource === "env" + ? "OPENCLAW_HOOKS_TOKEN" + : "agent-orchestrator.yaml"; + + if (nonInteractive) { + console.log(chalk.green("✓ AO config written (OpenClaw config left unchanged)")); + console.log(`Token source: ${tokenLocation}`); + return; + } + + console.log(`\n${chalk.green.bold("Done — AO config written.")}`); + console.log(chalk.dim(" agent-orchestrator.yaml — notifiers.openclaw block")); + console.log(chalk.dim(` token source — ${tokenLocation}`)); + if (resolved.tokenSource === "openclaw-config") { + console.log(chalk.dim(" AO did not write OpenClaw config or shell profile exports.")); + } +} + +async function runInteractiveSetupProbe(resolved: ResolvedOpenClawSetup): Promise { + if (!resolved.shouldSendTest) { + console.log(chalk.dim("Skipped OpenClaw setup probe.")); + return; + } + + const result = await validateToken(resolved.url, resolved.token); + if (result.valid) { + console.log(chalk.green("✓ OpenClaw setup probe passed")); + return; + } + + console.log( + chalk.yellow( + `OpenClaw setup probe did not pass yet: ${result.error ?? "unknown validation error"}`, + ), + ); + console.log(chalk.dim("Restart OpenClaw, then run `ao setup openclaw --status` to verify.")); +} + +async function printStatus(): Promise { + const context = readConfigContext(); + const existingOpenClaw = getExistingOpenClaw(context.rawConfig); + const plugin = stringValue(existingOpenClaw["plugin"]); + const configuredUrl = stringValue(existingOpenClaw["url"]); + const url = configuredUrl ? normalizeOpenClawHooksUrl(configuredUrl) : DEFAULT_OPENCLAW_URL; + const openclawConfigPath = getConfiguredOpenClawConfigPath({}, existingOpenClaw); + const tokenInfo = resolveConfiguredToken(existingOpenClaw, openclawConfigPath); + const openclawJson = readOpenClawJson(openclawConfigPath); + const installation = await detectOpenClawInstallation(url); + + console.log(chalk.bold("OpenClaw notifier status")); + console.log(` Config: ${context.configPath}`); + console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); + console.log(` Webhook URL: ${configuredUrl ?? chalk.dim("not configured")}`); + console.log(` Token: ${tokenInfo ? `configured from ${tokenInfo.source}` : chalk.dim("not configured")}`); + console.log( + ` OpenClaw config: ${openclawJson.exists ? displayOpenClawConfigPath(openclawJson.path) : chalk.dim(`${displayOpenClawConfigPath(openclawJson.path)} not found`)}`, + ); + console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "openclaw").label}`); + console.log(` Gateway: ${installation.state} at ${installation.gatewayUrl}`); + if (installation.binaryPath) console.log(` Binary: ${installation.binaryPath}`); + + if (plugin !== "openclaw" || !tokenInfo || installation.state !== "running") return; + + const validation = await validateToken(url, tokenInfo.value); + if (validation.valid) { + console.log(chalk.green(" Token probe: PASS")); + } else { + console.log(chalk.red(` Token probe: FAIL ${validation.error ?? "unknown error"}`)); + } +} + +export async function runOpenClawSetupAction(opts: OpenClawSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const force = Boolean(opts.force); + + if (opts.status) { + await printStatus(); + return; + } + + const context = readConfigContext(); + const existingOpenClaw = getExistingOpenClaw(context.rawConfig); + const shouldWire = await shouldReplaceConflictingOpenClaw( + existingOpenClaw["plugin"], + force, + nonInteractive, + ); + if (!shouldWire) return; + + const resolved = nonInteractive + ? await resolveNonInteractiveSetup(opts, existingOpenClaw, context.rawConfig) + : await resolveInteractiveSetup(opts, existingOpenClaw, context.rawConfig); + + writeOpenClawConfig(context.configPath, resolved, nonInteractive); + + if (!nonInteractive) { + await runInteractiveSetupProbe(resolved); + } + + printOpenClawInstructions(nonInteractive, resolved); + + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("OpenClaw setup complete!")} AO will send notifications to OpenClaw.\n` + + chalk.dim(" Test it with: ao notify test --to openclaw --template basic\n") + + chalk.dim(" Restart AO with 'ao stop && ao start' to activate."), + ); + } else { + console.log(chalk.green("\n✓ OpenClaw setup complete.")); + console.log(chalk.dim("Restart AO to activate: ao stop && ao start")); + } +} diff --git a/packages/cli/src/lib/slack-setup.ts b/packages/cli/src/lib/slack-setup.ts new file mode 100644 index 000000000..a86e0c08c --- /dev/null +++ b/packages/cli/src/lib/slack-setup.ts @@ -0,0 +1,628 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +const SLACK_APPS_URL = "https://api.slack.com/apps"; +const DEFAULT_USERNAME = "Agent Orchestrator"; +const SETUP_TIMEOUT_MS = 10_000; + +export interface SlackSetupOptions { + webhookUrl?: string; + channel?: string; + username?: string; + refresh?: boolean; + status?: boolean; + test?: boolean; + force?: boolean; + nonInteractive?: boolean; + routingPreset?: string; +} + +interface ConfigContext { + configPath: string; + rawConfig: Record; +} + +interface ResolvedSlackSetup { + webhookUrl: string; + channel?: string; + username: string; + shouldSendTest: boolean; + routingPreset?: NotifierRoutingPreset; +} + +export class SlackSetupError extends Error { + constructor( + message: string, + public readonly exitCode = 1, + ) { + super(message); + this.name = "SlackSetupError"; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function validateSlackWebhookUrl(webhookUrl: string): void { + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new SlackSetupError("Slack webhook URL is invalid."); + } + + const validHost = + parsed.hostname === "hooks.slack.com" || parsed.hostname === "hooks.slack-gov.com"; + if (parsed.protocol !== "https:" || !validHost || !parsed.pathname.startsWith("/services/")) { + throw new SlackSetupError( + "Slack webhook URL must look like https://hooks.slack.com/services/...", + ); + } +} + +function readConfigContext(): ConfigContext { + const configPath = findConfigFile() ?? undefined; + if (!configPath) { + throw new SlackSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingSlack(rawConfig: Record): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = notifiers["slack"]; + return isRecord(existing) ? existing : {}; +} + +function formatFetchError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function buildSetupPayload(resolved: ResolvedSlackSetup): Record { + const payload: Record = { + username: resolved.username, + text: "AO Slack notifications are ready.", + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "AO Slack notifications are ready", + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "This channel is now configured to receive AO notifications.", + }, + }, + ], + }; + + if (resolved.channel) payload["channel"] = resolved.channel; + return payload; +} + +async function sendSetupProbe(resolved: ResolvedSlackSetup): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS); + + try { + const response = await fetch(resolved.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildSetupPayload(resolved)), + signal: controller.signal, + }); + + if (response.ok) return; + + const body = await response.text().catch(() => ""); + throw new SlackSetupError( + `Slack setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`, + ); + } catch (error) { + if (error instanceof SlackSetupError) throw error; + throw new SlackSetupError(`Slack setup test failed: ${formatFetchError(error)}`); + } finally { + clearTimeout(timer); + } +} + +async function shouldReplaceConflictingSlack( + existingPlugin: unknown, + force: boolean, + nonInteractive: boolean, +): Promise { + if (existingPlugin === undefined || existingPlugin === "slack" || force) return true; + if (nonInteractive) { + throw new SlackSetupError( + `notifiers.slack already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`, + ); + } + + const clack = await import("@clack/prompts"); + const replace = await clack.confirm({ + message: `notifiers.slack already uses plugin "${String(existingPlugin)}". Replace it?`, + initialValue: false, + }); + + if (clack.isCancel(replace) || !replace) { + console.log(chalk.dim("Keeping existing Slack notifier config.")); + return false; + } + + return true; +} + +function printManualWebhookInstructions(): void { + console.log(""); + console.log(chalk.bold("Create a Slack incoming webhook")); + console.log(` 1. Open ${SLACK_APPS_URL}`); + console.log(" 2. Create a new app, or select an existing app."); + console.log(" 3. Open Incoming Webhooks and activate them."); + console.log(" 4. Click Add New Webhook to Workspace."); + console.log(" 5. Pick the channel AO should post to and authorize."); + console.log(" 6. Copy the generated webhook URL and paste it here."); + console.log( + chalk.dim("For private channels, the installing Slack user must already be in the channel."), + ); + console.log(""); +} + +function explainChannelBinding(): void { + console.log( + chalk.dim( + "Slack incoming webhook URLs are bound to the channel selected during Slack authorization. To change channels, create a webhook for that channel.", + ), + ); +} + +function cancelSetup(clack: ClackPrompts): never { + clack.cancel("Setup cancelled."); + throw new SlackSetupError("Setup cancelled.", 0); +} + +async function promptSlackWebhookUrl( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const webhookUrlInput = await clack.text({ + message: "Slack incoming webhook URL:", + placeholder: "https://hooks.slack.com/services/...", + initialValue, + validate: (value) => { + if (!value) return "Slack webhook URL is required"; + try { + validateSlackWebhookUrl(String(value)); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(webhookUrlInput)) { + cancelSetup(clack); + } + + return String(webhookUrlInput); +} + +async function promptAfterWebhookInstructions( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + printManualWebhookInstructions(); + + while (true) { + const next = await clack.select({ + message: "After creating the Slack webhook, what do you want to do?", + options: [ + { + value: "enter-url", + label: "Paste webhook URL", + hint: "Continue setup", + }, + { + value: "show-steps", + label: "Show steps again", + hint: "Reprint the Slack app URL and steps", + }, + { + value: "back", + label: "Back", + hint: "Return to the previous options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelSetup(clack); + } + + if (next === "back") return "back"; + if (next === "show-steps") { + printManualWebhookInstructions(); + continue; + } + if (next === "enter-url") { + return promptSlackWebhookUrl(clack, initialValue); + } + } +} + +async function promptChangeWebhookUrl( + clack: ClackPrompts, + existingWebhookUrl: string | undefined, +): Promise { + while (true) { + const next = await clack.select({ + message: "How do you want to change the Slack webhook URL?", + options: [ + { + value: "enter-url", + label: "Paste new webhook URL", + hint: "Use a different Slack incoming webhook URL", + }, + { + value: "need-url", + label: "Show me how to create a new webhook", + hint: "AO will print the Slack app URL and steps", + }, + { + value: "back", + label: "Back", + hint: "Return to the previous options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelSetup(clack); + } + + if (next === "back") return "back"; + if (next === "enter-url") { + return promptSlackWebhookUrl(clack, existingWebhookUrl); + } + if (next === "need-url") { + const result = await promptAfterWebhookInstructions(clack, undefined); + if (result === "back") continue; + return result; + } + } +} + +async function resolveInteractiveWebhookUrl( + clack: ClackPrompts, + opts: SlackSetupOptions, + existingWebhookUrl: string | undefined, +): Promise { + const providedWebhookUrl = stringValue(opts.webhookUrl); + if (providedWebhookUrl) return providedWebhookUrl; + + while (true) { + const source = existingWebhookUrl + ? await clack.select({ + message: "Slack notifier is already configured. What do you want to do?", + options: [ + { + value: "use-existing", + label: "Use existing webhook URL", + hint: "Keep sending to the currently configured Slack channel", + }, + { + value: "change-url", + label: "Change webhook URL", + hint: "Paste a different Slack incoming webhook URL", + }, + { + value: "need-url", + label: "Show me how to create a new webhook", + hint: "AO will print the Slack app URL and steps", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }) + : await clack.select({ + message: "Do you already have a Slack incoming webhook URL?", + options: [ + { + value: "have-url", + label: "Yes, I have the URL", + hint: "Paste the existing Slack webhook URL", + }, + { + value: "need-url", + label: "No, show me how to create one", + hint: "AO will print the Slack app URL and steps", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(source) || source === "cancel") { + cancelSetup(clack); + } + + if (source === "use-existing" && existingWebhookUrl) { + return existingWebhookUrl; + } + + if (source === "change-url") { + const result = await promptChangeWebhookUrl(clack, existingWebhookUrl); + if (result === "back") continue; + return result; + } + + if (source === "have-url") { + return promptSlackWebhookUrl(clack, undefined); + } + + if (source === "need-url") { + const result = await promptAfterWebhookInstructions(clack, undefined); + if (result === "back") continue; + return result; + } + } +} + +async function resolveInteractiveSetup( + opts: SlackSetupOptions, + existingSlack: Record, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + const existingWebhookUrl = stringValue(existingSlack["webhookUrl"]); + const optionRoutingPreset = resolveSlackRoutingPreset(opts.routingPreset); + + clack.intro(chalk.bgCyan(chalk.black(" ao setup slack "))); + explainChannelBinding(); + + while (true) { + const resolvedWebhookUrl = await resolveInteractiveWebhookUrl(clack, opts, existingWebhookUrl); + + const channelInput = await clack.text({ + message: "Channel name (optional; must match the channel selected when creating the webhook):", + placeholder: "#agents", + initialValue: stringValue(opts.channel) ?? stringValue(existingSlack["channel"]), + }); + + if (clack.isCancel(channelInput)) { + cancelSetup(clack); + } + + const usernameInput = await clack.text({ + message: "Display name (optional):", + placeholder: DEFAULT_USERNAME, + initialValue: + stringValue(opts.username) ?? stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME, + }); + + if (clack.isCancel(usernameInput)) { + cancelSetup(clack); + } + + const routingSelection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, "slack", "Slack", () => + cancelSetup(clack), + )); + if (routingSelection === "back") continue; + + return buildResolvedSetup( + resolvedWebhookUrl, + stringValue(channelInput), + stringValue(usernameInput), + routingSelection === "preserve" ? undefined : routingSelection, + opts, + ); + } +} + +function resolveNonInteractiveSetup( + opts: SlackSetupOptions, + existingSlack: Record, +): ResolvedSlackSetup { + const webhookUrl = + stringValue(opts.webhookUrl) ?? + (opts.refresh ? stringValue(existingSlack["webhookUrl"]) : undefined); + if (!webhookUrl) { + throw new SlackSetupError( + "Slack webhook URL is required. Pass --webhook-url, or run `ao setup slack --refresh` with an existing Slack config.", + ); + } + + const channel = stringValue(opts.channel) ?? stringValue(existingSlack["channel"]); + const username = + stringValue(opts.username) ?? stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME; + const routingPreset = resolveSlackRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"); + return buildResolvedSetup(webhookUrl, channel, username, routingPreset, opts); +} + +function buildResolvedSetup( + webhookUrl: string, + channel: string | undefined, + username: string | undefined, + routingPreset: NotifierRoutingPreset | undefined, + opts: SlackSetupOptions, +): ResolvedSlackSetup { + const normalizedWebhookUrl = webhookUrl.trim(); + validateSlackWebhookUrl(normalizedWebhookUrl); + return { + webhookUrl: normalizedWebhookUrl, + channel, + username: username ?? DEFAULT_USERNAME, + shouldSendTest: opts.test !== false, + routingPreset, + }; +} + +function resolveSlackRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "Slack") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new SlackSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function writeSlackConfig(configPath: string, resolved: ResolvedSlackSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existingSlack = isRecord(notifiers["slack"]) ? notifiers["slack"] : {}; + const slackConfig: Record = { + ...existingSlack, + plugin: "slack", + webhookUrl: resolved.webhookUrl, + username: resolved.username, + }; + if (resolved.channel) slackConfig["channel"] = resolved.channel; + else delete slackConfig["channel"]; + notifiers["slack"] = slackConfig; + rawConfig["notifiers"] = notifiers; + + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + rawConfig["defaults"] = defaults; + + applyNotifierRoutingPreset(rawConfig, "slack", resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +function webhookUrlStatus(webhookUrl: string | undefined): string { + if (!webhookUrl) return "not configured"; + try { + return `configured (${new URL(webhookUrl).hostname})`; + } catch { + return "configured"; + } +} + +async function printStatus(): Promise { + const context = readConfigContext(); + const existingSlack = getExistingSlack(context.rawConfig); + const plugin = stringValue(existingSlack["plugin"]); + const webhookUrl = stringValue(existingSlack["webhookUrl"]); + const channel = stringValue(existingSlack["channel"]); + const username = stringValue(existingSlack["username"]) ?? DEFAULT_USERNAME; + + console.log(chalk.bold("Slack notifier status")); + console.log(` Config: ${context.configPath}`); + console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); + console.log(` Webhook URL: ${webhookUrlStatus(webhookUrl)}`); + console.log(` Channel: ${channel ?? chalk.dim("webhook default")}`); + console.log(` Username: ${username}`); + console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "slack").label}`); + + if (plugin !== "slack" || !webhookUrl) return; + + try { + await sendSetupProbe(buildResolvedSetup(webhookUrl, channel, username, undefined, { test: true })); + console.log(chalk.green(" Probe: PASS")); + } catch (error) { + console.log( + chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`), + ); + } +} + +export async function runSlackSetupAction(opts: SlackSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const force = Boolean(opts.force); + + if (opts.status) { + await printStatus(); + return; + } + + const context = readConfigContext(); + const existingSlack = getExistingSlack(context.rawConfig); + const shouldWire = await shouldReplaceConflictingSlack( + existingSlack["plugin"], + force, + nonInteractive, + ); + if (!shouldWire) return; + + const resolved = nonInteractive + ? resolveNonInteractiveSetup(opts, existingSlack) + : await resolveInteractiveSetup(opts, existingSlack, context.rawConfig); + + if (resolved.shouldSendTest) { + await sendSetupProbe(resolved); + console.log(chalk.green("✓ Slack setup test passed")); + } else { + console.log(chalk.dim("Skipped Slack setup test.")); + } + + writeSlackConfig(context.configPath, resolved); + console.log(chalk.green(`✓ Config written to ${context.configPath}`)); + + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Slack setup complete!")} AO will send notifications through the configured Slack webhook.\n` + + chalk.dim(" Test it with: ao notify test --to slack --template basic"), + ); + } else { + console.log(chalk.green("\n✓ Slack setup complete.")); + console.log(chalk.dim("Test it with: ao notify test --to slack --template basic")); + } +} diff --git a/packages/cli/src/lib/webhook-setup.ts b/packages/cli/src/lib/webhook-setup.ts new file mode 100644 index 000000000..a292ad547 --- /dev/null +++ b/packages/cli/src/lib/webhook-setup.ts @@ -0,0 +1,536 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { parseDocument } from "yaml"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; +import { + applyNotifierRoutingPreset, + getNotifierRoutingState, + promptNotifierRoutingPreset, + resolveRoutingPresetOption, + type ClackPrompts, + type NotifierRoutingPreset, +} from "./notifier-routing.js"; + +const DEFAULT_RETRIES = 2; +const DEFAULT_RETRY_DELAY_MS = 1000; +const SETUP_TIMEOUT_MS = 10_000; + +export interface WebhookSetupOptions { + url?: string; + authToken?: string; + refresh?: boolean; + status?: boolean; + test?: boolean; + force?: boolean; + nonInteractive?: boolean; + routingPreset?: string; +} + +interface WebhookConfig { + plugin: "webhook"; + url: string; + headers?: Record; + retries: number; + retryDelayMs: number; +} + +interface ConfigContext { + configPath: string; + rawConfig: Record; +} + +interface ResolvedWebhookSetup { + url: string; + headers?: Record; + retries: number; + retryDelayMs: number; + shouldSendTest: boolean; + routingPreset?: NotifierRoutingPreset; +} + +export class WebhookSetupError extends Error { + constructor( + message: string, + public readonly exitCode = 1, + ) { + super(message); + this.name = "WebhookSetupError"; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function validateWebhookUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new WebhookSetupError("Webhook URL is invalid."); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new WebhookSetupError("Webhook URL must start with http:// or https://."); + } +} + +function readConfigContext(): ConfigContext { + const configPath = findConfigFile() ?? undefined; + if (!configPath) { + throw new WebhookSetupError( + "No agent-orchestrator.yaml found. Run 'ao start' first to create one.", + ); + } + + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + return { configPath, rawConfig }; +} + +function getExistingWebhook(rawConfig: Record): Record { + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const existing = notifiers["webhook"]; + return isRecord(existing) ? existing : {}; +} + +function getBearerToken(headers: unknown): string | undefined { + if (!isRecord(headers)) return undefined; + const authorization = stringValue(headers["Authorization"] ?? headers["authorization"]); + const match = authorization?.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim(); +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback; +} + +function formatFetchError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function buildSetupPayload(): Record { + return { + type: "notification", + event: { + id: `webhook-setup-${Date.now()}`, + type: "setup.webhook", + priority: "info", + sessionId: "setup", + projectId: "ao", + timestamp: new Date().toISOString(), + message: "AO webhook notifications are ready.", + data: { + source: "ao-setup-webhook", + }, + }, + }; +} + +async function sendSetupProbe(resolved: ResolvedWebhookSetup): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS); + + try { + const response = await fetch(resolved.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(resolved.headers ?? {}), + }, + body: JSON.stringify(buildSetupPayload()), + signal: controller.signal, + }); + + if (response.ok) return; + + const body = await response.text().catch(() => ""); + throw new WebhookSetupError( + `Webhook setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`, + ); + } catch (error) { + if (error instanceof WebhookSetupError) throw error; + throw new WebhookSetupError(`Webhook setup test failed: ${formatFetchError(error)}`); + } finally { + clearTimeout(timer); + } +} + +async function shouldReplaceConflictingWebhook( + existingPlugin: unknown, + force: boolean, + nonInteractive: boolean, +): Promise { + if (existingPlugin === undefined || existingPlugin === "webhook" || force) return true; + if (nonInteractive) { + throw new WebhookSetupError( + `notifiers.webhook already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`, + ); + } + + const clack = await import("@clack/prompts"); + const replace = await clack.confirm({ + message: `notifiers.webhook already uses plugin "${String(existingPlugin)}". Replace it?`, + initialValue: false, + }); + + if (clack.isCancel(replace) || !replace) { + console.log(chalk.dim("Keeping existing webhook notifier config.")); + return false; + } + + return true; +} + +function cancelSetup(clack: ClackPrompts): never { + clack.cancel("Setup cancelled."); + throw new WebhookSetupError("Setup cancelled.", 0); +} + +async function promptWebhookUrl( + clack: ClackPrompts, + initialValue: string | undefined, +): Promise { + const urlInput = await clack.text({ + message: "Webhook URL:", + placeholder: "https://example.com/ao-events", + initialValue, + validate: (value) => { + if (!value) return "URL is required"; + try { + validateWebhookUrl(String(value)); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + if (clack.isCancel(urlInput)) { + cancelSetup(clack); + } + + return String(urlInput); +} + +async function promptChangeWebhookUrl( + clack: ClackPrompts, + existingUrl: string | undefined, +): Promise { + const next = await clack.select({ + message: "How do you want to configure the webhook URL?", + options: [ + { + value: "enter-url", + label: "Add new webhook URL", + hint: "Paste a different HTTP endpoint", + }, + { + value: "back", + label: "Back", + hint: "Return to the previous options", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(next) || next === "cancel") { + cancelSetup(clack); + } + + if (next === "back") return "back"; + return promptWebhookUrl(clack, existingUrl); +} + +async function resolveInteractiveUrl( + clack: ClackPrompts, + opts: WebhookSetupOptions, + existingUrl: string | undefined, +): Promise { + const providedUrl = stringValue(opts.url); + if (providedUrl) return providedUrl; + + while (true) { + if (!existingUrl) { + return promptWebhookUrl(clack, undefined); + } + + const source = await clack.select({ + message: "Webhook notifier is already configured. What do you want to do?", + options: [ + { + value: "use-existing", + label: "Use existing webhook URL", + hint: "Keep sending to the currently configured endpoint", + }, + { + value: "add-new", + label: "Add new webhook URL", + hint: "Paste a different HTTP endpoint", + }, + { + value: "cancel", + label: "Cancel setup", + hint: "Do not change config", + }, + ], + }); + + if (clack.isCancel(source) || source === "cancel") { + cancelSetup(clack); + } + + if (source === "use-existing") { + return existingUrl; + } + + if (source === "add-new") { + const result = await promptChangeWebhookUrl(clack, existingUrl); + if (result === "back") continue; + return result; + } + } +} + +async function resolveInteractiveSetup( + opts: WebhookSetupOptions, + existingWebhook: Record, + rawConfig: Record, +): Promise { + const clack = await import("@clack/prompts"); + const existingUrl = stringValue(existingWebhook["url"]); + const existingToken = getBearerToken(existingWebhook["headers"]); + const optionRoutingPreset = resolveWebhookRoutingPreset(opts.routingPreset); + + clack.intro(chalk.bgCyan(chalk.black(" ao setup webhook "))); + + while (true) { + const resolvedUrl = await resolveInteractiveUrl(clack, opts, existingUrl); + + let authToken = stringValue(opts.authToken); + if (!authToken && existingToken) { + const keepExisting = await clack.confirm({ + message: "Keep the existing Authorization bearer token?", + initialValue: true, + }); + + if (clack.isCancel(keepExisting)) { + cancelSetup(clack); + } + + if (keepExisting) authToken = existingToken; + } + + if (!authToken) { + const tokenInput = await clack.password({ + message: "Auth token (optional; leave blank for none):", + }); + + if (clack.isCancel(tokenInput)) { + cancelSetup(clack); + } + + authToken = stringValue(tokenInput); + } + + const routingSelection = + optionRoutingPreset ?? + (await promptNotifierRoutingPreset(clack, rawConfig, "webhook", "webhook", () => + cancelSetup(clack), + )); + if (routingSelection === "back") continue; + + const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES); + const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS); + + return buildResolvedSetup( + resolvedUrl, + authToken, + retries, + retryDelayMs, + routingSelection === "preserve" ? undefined : routingSelection, + opts, + ); + } +} + +function resolveNonInteractiveSetup( + opts: WebhookSetupOptions, + existingWebhook: Record, +): ResolvedWebhookSetup { + const url = + stringValue(opts.url) ?? (opts.refresh ? stringValue(existingWebhook["url"]) : undefined); + if (!url) { + throw new WebhookSetupError( + "Webhook URL is required. Pass --url, or run `ao setup webhook --refresh` with an existing webhook config.", + ); + } + + const authToken = stringValue(opts.authToken) ?? getBearerToken(existingWebhook["headers"]); + const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES); + const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS); + const routingPreset = + resolveWebhookRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"); + + return buildResolvedSetup(url, authToken, retries, retryDelayMs, routingPreset, opts); +} + +function buildResolvedSetup( + url: string, + authToken: string | undefined, + retries: number, + retryDelayMs: number, + routingPreset: NotifierRoutingPreset | undefined, + opts: WebhookSetupOptions, +): ResolvedWebhookSetup { + const normalizedUrl = url.trim(); + validateWebhookUrl(normalizedUrl); + const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; + return { + url: normalizedUrl, + headers, + retries, + retryDelayMs, + shouldSendTest: opts.test !== false, + routingPreset, + }; +} + +function resolveWebhookRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined { + try { + return resolveRoutingPresetOption(value, "webhook") as NotifierRoutingPreset | undefined; + } catch (error) { + throw new WebhookSetupError(error instanceof Error ? error.message : String(error)); + } +} + +function toWebhookConfig(resolved: ResolvedWebhookSetup): WebhookConfig { + return { + plugin: "webhook", + url: resolved.url, + ...(resolved.headers ? { headers: resolved.headers } : {}), + retries: resolved.retries, + retryDelayMs: resolved.retryDelayMs, + }; +} + +function writeWebhookConfig(configPath: string, resolved: ResolvedWebhookSetup): void { + const rawYaml = readFileSync(configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record) ?? {}; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + notifiers["webhook"] = toWebhookConfig(resolved); + rawConfig["notifiers"] = notifiers; + + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + rawConfig["defaults"] = defaults; + + applyNotifierRoutingPreset(rawConfig, "webhook", resolved.routingPreset); + + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + if (rawConfig["notificationRouting"] !== undefined) { + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + } + writeFileSync(configPath, doc.toString({ indent: 2 })); +} + +async function printStatus(): Promise { + const context = readConfigContext(); + const existingWebhook = getExistingWebhook(context.rawConfig); + const plugin = stringValue(existingWebhook["plugin"]); + const url = stringValue(existingWebhook["url"]); + const hasAuth = Boolean(getBearerToken(existingWebhook["headers"])); + const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES); + const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS); + + console.log(chalk.bold("Webhook notifier status")); + console.log(` Config: ${context.configPath}`); + console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`); + console.log(` URL: ${url ?? chalk.dim("not configured")}`); + console.log(` Auth: ${hasAuth ? "Authorization bearer token configured" : "none"}`); + console.log(` Retries: ${retries}`); + console.log(` Retry delay: ${retryDelayMs}ms`); + console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "webhook").label}`); + + if (plugin !== "webhook" || !url) return; + + try { + await sendSetupProbe( + buildResolvedSetup( + url, + getBearerToken(existingWebhook["headers"]), + retries, + retryDelayMs, + undefined, + { test: true }, + ), + ); + console.log(chalk.green(" Probe: PASS")); + } catch (error) { + console.log( + chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`), + ); + } +} + +export async function runWebhookSetupAction(opts: WebhookSetupOptions): Promise { + const nonInteractive = opts.nonInteractive || !process.stdin.isTTY; + const force = Boolean(opts.force); + + if (opts.status) { + await printStatus(); + return; + } + + const context = readConfigContext(); + const existingWebhook = getExistingWebhook(context.rawConfig); + const shouldWire = await shouldReplaceConflictingWebhook( + existingWebhook["plugin"], + force, + nonInteractive, + ); + if (!shouldWire) return; + + const resolved = nonInteractive + ? resolveNonInteractiveSetup(opts, existingWebhook) + : await resolveInteractiveSetup(opts, existingWebhook, context.rawConfig); + + if (resolved.shouldSendTest) { + await sendSetupProbe(resolved); + console.log(chalk.green("✓ Webhook setup test passed")); + } else { + console.log(chalk.dim("Skipped webhook setup test.")); + } + + writeWebhookConfig(context.configPath, resolved); + console.log(chalk.green(`✓ Config written to ${context.configPath}`)); + + if (!nonInteractive) { + const clack = await import("@clack/prompts"); + clack.outro( + `${chalk.green("Webhook setup complete!")} AO will send notifications to ${resolved.url}.\n` + + chalk.dim(" Test it with: ao notify test --to webhook --template basic"), + ); + } else { + console.log(chalk.green("\n✓ Webhook setup complete.")); + console.log(chalk.dim("Test it with: ao notify test --to webhook --template basic")); + } +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts index 6e0519a88..108118d5a 100644 --- a/packages/cli/src/program.ts +++ b/packages/cli/src/program.ts @@ -14,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"; @@ -48,6 +49,7 @@ export function createProgram(): Command { registerUpdate(program); registerSetup(program); registerPlugin(program); + registerNotify(program); registerProjectCommand(program); registerMigrateStorage(program); registerCompletion(program); diff --git a/packages/core/__tests__/config.test.ts b/packages/core/__tests__/config.test.ts index 1bd68af56..041aac7ef 100644 --- a/packages/core/__tests__/config.test.ts +++ b/packages/core/__tests__/config.test.ts @@ -157,6 +157,9 @@ projects: globalConfigPath, [ "port: 4000", + "observability:", + " logLevel: info", + " stderr: true", "defaults:", " runtime: tmux", " agent: claude-code", @@ -185,6 +188,7 @@ projects: ); const config = loadConfig(globalConfigPath); + expect(config.observability).toEqual({ logLevel: "info", stderr: true }); expect(Object.keys(config.projects)).toEqual(["clean-project"]); expect(config.projects["clean-project"]).toBeDefined(); expect(config.degradedProjects["broken-project"]).toMatchObject({ diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 1a6ce9c49..8dd359161 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -1247,3 +1247,61 @@ describe("Config Validation - Power Config", () => { ).toThrow(); }); }); + +describe("Config Validation - Observability Config", () => { + const baseConfig = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + }, + }, + }; + + it("applies default observability config", () => { + const config = validateConfig(baseConfig); + + expect(config.observability).toEqual({ + logLevel: "warn", + stderr: false, + }); + }); + + it("accepts explicit observability settings", () => { + const config = validateConfig({ + ...baseConfig, + observability: { + logLevel: "info", + stderr: true, + }, + }); + + expect(config.observability).toEqual({ + logLevel: "info", + stderr: true, + }); + }); + + it("rejects invalid observability settings", () => { + expect(() => + validateConfig({ + ...baseConfig, + observability: { + logLevel: "verbose", + stderr: false, + }, + }), + ).toThrow(); + + expect(() => + validateConfig({ + ...baseConfig, + observability: { + logLevel: "warn", + stderr: "yes", + }, + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/__tests__/dashboard-notifications.test.ts b/packages/core/src/__tests__/dashboard-notifications.test.ts new file mode 100644 index 000000000..bea947638 --- /dev/null +++ b/packages/core/src/__tests__/dashboard-notifications.test.ts @@ -0,0 +1,146 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OrchestratorEvent } from "../types.js"; +import { buildCIFailureNotificationData } from "../notification-data.js"; +import { + appendDashboardNotificationRecord, + createDashboardNotificationRecord, + normalizeDashboardNotificationLimit, + readDashboardNotificationsFromFile, + writeDashboardNotificationsToFile, +} from "../dashboard-notifications.js"; + +let tempDir: string | null = null; + +function makeTempPath(): string { + tempDir = mkdtempSync(join(tmpdir(), "ao-dashboard-notifications-")); + return join(tempDir, "dashboard-notifications.jsonl"); +} + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "ci.failing", + priority: "action", + sessionId: "app-1", + projectId: "demo", + timestamp: new Date("2026-05-13T10:00:00.000Z"), + message: "CI is failing", + data: buildCIFailureNotificationData({ + sessionId: "app-1", + projectId: "demo", + context: { + pr: { + number: 1, + url: "https://github.com/acme/app/pull/1", + title: "Fix dashboard notifications", + branch: "fix/dashboard-notifications", + baseBranch: "main", + owner: "acme", + repo: "app", + isDraft: false, + }, + issueId: "AO-1", + issueTitle: "Fix dashboard notifications", + summary: "Fix dashboard notifications", + branch: "fix/dashboard-notifications", + }, + failedChecks: [{ name: "typecheck", status: "failed", conclusion: "FAILURE" }], + }), + ...overrides, + }; +} + +afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +describe("dashboard notifications", () => { + it("serializes events and actions into dashboard records", () => { + const record = createDashboardNotificationRecord( + makeEvent(), + [{ label: "View PR", url: "https://github.com/acme/app/pull/1" }], + new Date("2026-05-13T11:00:00.000Z"), + ); + + expect(record.id).toBe("evt-1:2026-05-13T11:00:00.000Z"); + expect(record.event.timestamp).toBe("2026-05-13T10:00:00.000Z"); + expect(record.event.data).toMatchObject({ + schemaVersion: 3, + subject: { pr: { url: "https://github.com/acme/app/pull/1" } }, + ci: { status: "failing" }, + }); + expect(record.event.data.prUrl).toBeUndefined(); + expect(record.actions).toEqual([ + { label: "View PR", url: "https://github.com/acme/app/pull/1" }, + ]); + }); + + it("writes and reads JSONL records", () => { + const filePath = makeTempPath(); + const record = createDashboardNotificationRecord(makeEvent()); + + writeDashboardNotificationsToFile(filePath, [record], 50); + + expect(readDashboardNotificationsFromFile(filePath)).toEqual([record]); + expect(readFileSync(filePath, "utf-8")).toContain('"sessionId":"app-1"'); + }); + + it("retains only the latest limit records", () => { + const filePath = makeTempPath(); + for (let i = 1; i <= 4; i++) { + appendDashboardNotificationRecord( + filePath, + createDashboardNotificationRecord( + makeEvent({ id: `evt-${i}` }), + undefined, + new Date(`2026-05-13T11:00:0${i}.000Z`), + ), + 2, + ); + } + + expect( + readDashboardNotificationsFromFile(filePath, 50).map((record) => record.event.id), + ).toEqual(["evt-3", "evt-4"]); + }); + + it("skips malformed JSONL lines", () => { + const filePath = makeTempPath(); + const record = createDashboardNotificationRecord(makeEvent()); + writeFileSync(filePath, `not-json\n${JSON.stringify(record)}\n{"id":"bad"}\n`, "utf-8"); + + expect(readDashboardNotificationsFromFile(filePath)).toEqual([record]); + }); + + it("keeps legacy JSONL records readable without transforming their data", () => { + const filePath = makeTempPath(); + const legacyRecord = { + id: "evt-legacy:2026-05-13T11:00:00.000Z", + receivedAt: "2026-05-13T11:00:00.000Z", + event: { + id: "evt-legacy", + type: "ci.failing", + priority: "warning", + sessionId: "app-1", + projectId: "demo", + timestamp: "2026-05-13T10:00:00.000Z", + message: "CI is failing", + data: { schemaVersion: 2, prUrl: "https://github.com/acme/app/pull/1" }, + }, + }; + writeFileSync(filePath, `${JSON.stringify(legacyRecord)}\n`, "utf-8"); + + expect(readDashboardNotificationsFromFile(filePath)).toEqual([legacyRecord]); + }); + + it("clamps invalid limits", () => { + expect(normalizeDashboardNotificationLimit(undefined)).toBe(50); + expect(normalizeDashboardNotificationLimit(0)).toBe(1); + expect(normalizeDashboardNotificationLimit("1000")).toBe(500); + expect(normalizeDashboardNotificationLimit("75")).toBe(75); + }); +}); diff --git a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts index 70b0b0296..d1d844899 100644 --- a/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager-instrumentation.test.ts @@ -11,8 +11,8 @@ * test that proves the lifecycle check completes successfully even if * recordActivityEvent itself throws (B2 invariant). * - * Notifier instrumentation is intentionally omitted — the notifier subsystem - * is undergoing larger work and AE evidence there is not currently useful. + * Notification delivery instrumentation is covered in lifecycle-manager.test.ts + * because it needs real observability snapshots in addition to activity events. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import type * as ActivityEventsModule from "../activity-events.js"; diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 13b8ef32a..e78146fa1 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -1770,7 +1770,11 @@ describe("check (single session)", () => { expect(notifier.notify).toHaveBeenCalledWith( expect.objectContaining({ type: "reaction.triggered", - data: expect.objectContaining({ reactionKey: "pr-closed" }), + data: expect.objectContaining({ + schemaVersion: 3, + semanticType: "pr.closed", + reaction: expect.objectContaining({ key: "pr-closed" }), + }), }), ); }); @@ -1867,10 +1871,11 @@ describe("reactions", () => { const reactionNotifications = vi.mocked(notifier.notify).mock.calls.filter((call) => { const event = call[0] as { type?: string; data?: Record } | undefined; - return ( - event?.type === "reaction.triggered" && - event.data?.["reactionKey"] === "report-no-acknowledge" - ); + const reaction = + event?.data?.reaction && typeof event.data.reaction === "object" + ? (event.data.reaction as Record) + : null; + return event?.type === "reaction.triggered" && reaction?.key === "report-no-acknowledge"; }); expect(reactionNotifications).toHaveLength(1); @@ -2839,6 +2844,112 @@ describe("reactions", () => { expect(notifier.notify).toHaveBeenCalledWith( expect.objectContaining({ type: "merge.completed" }), ); + + const summary = readObservabilitySummary(config); + expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.success).toBe(1); + expect( + summary.projects["my-app"]?.recentTraces.some( + (trace) => + trace.operation === "notification.deliver" && + trace.outcome === "success" && + trace.data?.["targetReference"] === "desktop", + ), + ).toBe(true); + }); + + it("records notifier delivery failures without interrupting lifecycle transitions", async () => { + const notifier = createMockNotifier(); + vi.mocked(notifier.notify).mockRejectedValue(new Error("webhook failed")); + const mockSCM = createMockSCM({ + getPRState: vi.fn().mockResolvedValue("merged"), + enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }), + }); + + const registry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "scm") return mockSCM; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + const lm = setupCheck("app-1", { + session: makeSession({ status: "approved", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("merged"); + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notification.delivery_failed", + level: "warn", + data: expect.objectContaining({ + eventType: "merge.completed", + targetReference: "desktop", + targetPlugin: "desktop", + }), + }), + ); + + const summary = readObservabilitySummary(config); + expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1); + expect(summary.projects["my-app"]?.health["notification.delivery.desktop"]?.status).toBe( + "warn", + ); + }); + + it("records missing notifier targets as delivery failures", async () => { + const mockSCM = createMockSCM({ + getPRState: vi.fn().mockResolvedValue("merged"), + enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged" }), + }); + const configWithMissingNotifier: OrchestratorConfig = { + ...config, + notificationRouting: { + ...config.notificationRouting, + action: ["missing"], + }, + }; + + const registry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "scm") return mockSCM; + return null; + }), + }; + + const lm = setupCheck("app-1", { + session: makeSession({ status: "approved", pr: makePR() }), + registry, + configOverride: configWithMissingNotifier, + }); + + await lm.check("app-1"); + + expect(recordActivityEvent).toHaveBeenCalledWith( + expect.objectContaining({ + source: "notifier", + kind: "notification.target_missing", + level: "warn", + data: expect.objectContaining({ + eventType: "merge.completed", + targetReference: "missing", + targetPlugin: "missing", + }), + }), + ); + + const summary = readObservabilitySummary(configWithMissingNotifier); + expect(summary.projects["my-app"]?.metrics["notification_delivery"]?.failure).toBe(1); }); it("resolves notifier aliases from notificationRouting before dispatch", async () => { @@ -3344,22 +3455,34 @@ describe("reactions", () => { // Reach escalated state: attempt 1 → send, attempt 2 → escalate await lm.check("app-1"); // pr_open → ci_failed: attempt 1, send vi.mocked(mockSessionManager.send).mockClear(); - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // ci_failed → pr_open - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); // pr_open → ci_failed: attempt 2 → escalate - expect(notifier.notify).toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" })); + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.escalated" }), + ); vi.mocked(notifier.notify).mockClear(); // ONE passing poll (stableCount = 1, not enough) - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // Next CI failure: tracker still escalated → short-circuit - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); expect(mockSessionManager.send).not.toHaveBeenCalled(); - expect(notifier.notify).not.toHaveBeenCalledWith(expect.objectContaining({ type: "reaction.escalated" })); + expect(notifier.notify).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.escalated" }), + ); }); it("pending CI does not count toward ci-failed tracker resolution", async () => { @@ -3401,12 +3524,16 @@ describe("reactions", () => { vi.mocked(mockSessionManager.send).mockClear(); // CI goes pending (agent pushed a fix, new run started): ci_failed → pr_open - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "pending" }), + ); await lm.check("app-1"); // stableCount must NOT increment await lm.check("app-1"); // two pending polls — must NOT clear tracker // CI fails again (run completed failing): pr_open → ci_failed, attempt 2 — send - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); // If pending had wrongly cleared the tracker, this would be attempt 1 (fresh), not attempt 2. // Attempt 2 ≤ retries:2 → sends to agent (not escalates) @@ -3414,10 +3541,14 @@ describe("reactions", () => { vi.mocked(mockSessionManager.send).mockClear(); // CI goes pending again, then failing — attempt 3 > retries:2 → escalate - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "pending" }), + ); await lm.check("app-1"); // pending: no clear await lm.check("app-1"); // pending: no clear - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); expect(mockSessionManager.send).not.toHaveBeenCalled(); // escalated, not sent to agent }); @@ -3461,25 +3592,35 @@ describe("reactions", () => { // Reach escalated state: attempt 1 → send, attempt 2 → escalate await lm.check("app-1"); // attempt 1, send vi.mocked(mockSessionManager.send).mockClear(); - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // ci_failed → pr_open - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); // attempt 2 → escalate vi.mocked(notifier.notify).mockClear(); // CI goes pending (new run) — stableCount stays 0, does NOT progress toward resolution - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "pending" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "pending" }), + ); await lm.check("app-1"); await lm.check("app-1"); await lm.check("app-1"); // many pending polls — stableCount never reaches threshold // CI finally passes (2 stable polls) → tracker cleared - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "passing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "passing" }), + ); await lm.check("app-1"); // stableCount = 1 await lm.check("app-1"); // stableCount = 2 → clearReactionTracker // Next CI failure gets fresh budget: attempt 1, send - vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation(mockBatchEnrichment({ ciStatus: "failing" })); + vi.mocked(mockSCM.enrichSessionsPRBatch!).mockImplementation( + mockBatchEnrichment({ ciStatus: "failing" }), + ); await lm.check("app-1"); expect(mockSessionManager.send).toHaveBeenCalledTimes(1); expect(notifier.notify).not.toHaveBeenCalledWith( @@ -3632,7 +3773,11 @@ describe("pollAll terminal status accounting", () => { .mock.calls.filter((call: unknown[]) => { const event = call[0] as Record | undefined; const data = event?.data as Record | undefined; - return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete"; + const reaction = + data?.reaction && typeof data.reaction === "object" + ? (data.reaction as Record) + : null; + return event?.type === "reaction.triggered" && reaction?.key === "all-complete"; }); expect(allCompleteNotifications).toHaveLength(0); @@ -4210,14 +4355,19 @@ describe("event enrichment", () => { expect.objectContaining({ type: "pr.closed", data: expect.objectContaining({ - context: expect.objectContaining({ + schemaVersion: 3, + subject: expect.objectContaining({ pr: expect.objectContaining({ url: "https://github.com/org/repo/pull/42", number: 42, }), branch: "feat/test-123", }), - schemaVersion: 2, + transition: expect.objectContaining({ + kind: "pr_state", + from: "none", + to: "closed", + }), }), }), ); @@ -4257,11 +4407,13 @@ describe("event enrichment", () => { expect.objectContaining({ type: "pr.closed", data: expect.objectContaining({ - context: expect.objectContaining({ - issueId: "INT-123", - issueTitle: "Fix login bug", + schemaVersion: 3, + subject: expect.objectContaining({ + issue: { + id: "INT-123", + title: "Fix login bug", + }, }), - schemaVersion: 2, }), }), ); @@ -4305,11 +4457,15 @@ describe("event enrichment", () => { expect.objectContaining({ type: "session.needs_input", data: expect.objectContaining({ - context: expect.objectContaining({ - pr: null, - issueId: "INT-456", + schemaVersion: 3, + subject: expect.objectContaining({ + issue: { id: "INT-456" }, + }), + transition: expect.objectContaining({ + kind: "session_status", + from: "working", + to: "needs_input", }), - schemaVersion: 2, }), }), ); diff --git a/packages/core/src/__tests__/notification-data.test.ts b/packages/core/src/__tests__/notification-data.test.ts new file mode 100644 index 000000000..1358d9d19 --- /dev/null +++ b/packages/core/src/__tests__/notification-data.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + buildCIFailureNotificationData, + buildPRStateNotificationData, + buildReactionEscalationNotificationData, + buildReactionNotificationData, + buildSessionTransitionNotificationData, + getNotificationDataV3, + semanticTypeForReactionKey, + type NotificationEventContext, +} from "../notification-data.js"; + +const context: NotificationEventContext = { + pr: { + number: 42, + url: "https://github.com/acme/app/pull/42", + title: "Normalize notifier payloads", + branch: "ao/notifier-payloads", + baseBranch: "main", + owner: "acme", + repo: "app", + isDraft: false, + }, + issueId: "AO-42", + issueTitle: "Notifier payloads", + summary: "Normalize notifier payloads", + branch: "ao/notifier-payloads", +}; + +describe("notification data v3", () => { + it("builds semantic session transition data without legacy flat fields", () => { + const data = buildSessionTransitionNotificationData({ + eventType: "session.needs_input", + sessionId: "worker-1", + projectId: "demo", + context, + oldStatus: "working", + newStatus: "needs_input", + }); + + expect(data).toMatchObject({ + schemaVersion: 3, + semanticType: "session.needs_input", + subject: { + session: { id: "worker-1", projectId: "demo" }, + pr: { number: 42, url: "https://github.com/acme/app/pull/42" }, + issue: { id: "AO-42" }, + }, + transition: { kind: "session_status", from: "working", to: "needs_input" }, + }); + expect(data).not.toHaveProperty("context"); + expect(data).not.toHaveProperty("prUrl"); + expect(data).not.toHaveProperty("oldStatus"); + expect(data).not.toHaveProperty("newStatus"); + }); + + it("builds CI failure data with nested check details", () => { + const data = buildCIFailureNotificationData({ + sessionId: "worker-1", + projectId: "demo", + context, + failedChecks: [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: "https://github.com/acme/app/actions/runs/1", + }, + ], + }); + + expect(data).toMatchObject({ + semanticType: "ci.failing", + ci: { + status: "failing", + failedChecks: [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: "https://github.com/acme/app/actions/runs/1", + }, + ], + }, + }); + expect(data).not.toHaveProperty("failedChecks", ["typecheck"]); + }); + + it("maps reaction keys to semantic domain blocks", () => { + const data = buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId: "worker-1", + projectId: "demo", + context, + reactionKey: "approved-and-green", + action: "notify", + enrichment: { + state: "open", + ciStatus: "passing", + reviewDecision: "approved", + mergeable: true, + hasConflicts: false, + isBehind: false, + }, + }); + + expect(data.semanticType).toBe("merge.ready"); + expect(data.reaction).toEqual({ key: "approved-and-green", action: "notify" }); + expect(data.ci).toEqual({ status: "passing" }); + expect(data.review).toEqual({ decision: "approved" }); + expect(data.merge).toMatchObject({ ready: true, conflicts: false, baseBranch: "main" }); + }); + + it("adds escalation details to reaction data", () => { + const data = buildReactionEscalationNotificationData({ + eventType: "reaction.escalated", + sessionId: "worker-1", + projectId: "demo", + context, + reactionKey: "ci-failed", + action: "escalated", + attempts: 4, + cause: "max_retries", + durationMs: 12_000, + }); + + expect(data.semanticType).toBe("ci.failing"); + expect(data.escalation).toEqual({ + attempts: 4, + cause: "max_retries", + durationMs: 12_000, + }); + }); + + it("builds PR state transition data", () => { + const data = buildPRStateNotificationData({ + eventType: "pr.closed", + sessionId: "worker-1", + projectId: "demo", + context, + oldPRState: "open", + newPRState: "closed", + }); + + expect(data.transition).toEqual({ kind: "pr_state", from: "open", to: "closed" }); + expect(data.subject.pr?.url).toBe("https://github.com/acme/app/pull/42"); + expect(data).not.toHaveProperty("oldPRState"); + expect(data).not.toHaveProperty("newPRState"); + }); + + it("recognizes only v3 notification data", () => { + const data = buildCIFailureNotificationData({ + sessionId: "worker-1", + projectId: "demo", + context, + failedChecks: [], + }); + + expect(getNotificationDataV3(data)).toBe(data); + expect(getNotificationDataV3({ schemaVersion: 2, prUrl: context.pr?.url })).toBeNull(); + }); + + it("falls back to event type for unknown reaction keys", () => { + expect(semanticTypeForReactionKey("custom-reaction", "reaction.triggered")).toBe( + "reaction.triggered", + ); + }); +}); diff --git a/packages/core/src/__tests__/observability.test.ts b/packages/core/src/__tests__/observability.test.ts index 3b034eb5a..78ca4fed3 100644 --- a/packages/core/src/__tests__/observability.test.ts +++ b/packages/core/src/__tests__/observability.test.ts @@ -159,6 +159,105 @@ describe("observability snapshot", () => { } }); + it("uses YAML observability log level and stderr settings", () => { + const originalLogLevel = process.env["AO_LOG_LEVEL"]; + const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"]; + delete process.env["AO_LOG_LEVEL"]; + delete process.env["AO_OBSERVABILITY_STDERR"]; + config.observability = { logLevel: "warn", stderr: true }; + + const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + try { + const observer = createProjectObserver(config, "session-manager"); + observer.recordOperation({ + metric: "spawn", + operation: "notification.success-no-audit", + outcome: "success", + correlationId: "corr-info", + projectId: "my-app", + level: "info", + }); + observer.recordOperation({ + metric: "notification_delivery", + operation: "notification.deliver", + outcome: "failure", + correlationId: "corr-warn", + projectId: "my-app", + sessionId: "app-1", + reason: "notifier target not found", + level: "warn", + }); + + const auditDir = join(getObservabilityBaseDir(config.configPath), "processes"); + const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson")); + const auditLog = auditFiles + .map((fileName) => readFileSync(join(auditDir, fileName), "utf-8")) + .join("\n"); + + expect(auditLog).toContain('"operation":"notification.deliver"'); + expect(auditLog).not.toContain("notification.success-no-audit"); + expect(stderrSpy).toHaveBeenCalledTimes(1); + expect(String(stderrSpy.mock.calls[0]?.[0])).toContain('"operation":"notification.deliver"'); + } finally { + stderrSpy.mockRestore(); + if (originalLogLevel === undefined) { + delete process.env["AO_LOG_LEVEL"]; + } else { + process.env["AO_LOG_LEVEL"] = originalLogLevel; + } + if (originalObservabilityStderr === undefined) { + delete process.env["AO_OBSERVABILITY_STDERR"]; + } else { + process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr; + } + } + }); + + it("lets observability env vars override YAML settings", () => { + const originalLogLevel = process.env["AO_LOG_LEVEL"]; + const originalObservabilityStderr = process.env["AO_OBSERVABILITY_STDERR"]; + process.env["AO_LOG_LEVEL"] = "info"; + process.env["AO_OBSERVABILITY_STDERR"] = "0"; + config.observability = { logLevel: "error", stderr: true }; + + const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + try { + const observer = createProjectObserver(config, "session-manager"); + observer.recordOperation({ + metric: "notification_delivery", + operation: "notification.deliver", + outcome: "success", + correlationId: "corr-env", + projectId: "my-app", + sessionId: "app-1", + level: "info", + }); + + const auditDir = join(getObservabilityBaseDir(config.configPath), "processes"); + const auditFiles = readdirSync(auditDir).filter((fileName) => fileName.endsWith(".ndjson")); + const auditLog = auditFiles + .map((fileName) => readFileSync(join(auditDir, fileName), "utf-8")) + .join("\n"); + + expect(auditLog).toContain('"operation":"notification.deliver"'); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + stderrSpy.mockRestore(); + if (originalLogLevel === undefined) { + delete process.env["AO_LOG_LEVEL"]; + } else { + process.env["AO_LOG_LEVEL"] = originalLogLevel; + } + if (originalObservabilityStderr === undefined) { + delete process.env["AO_OBSERVABILITY_STDERR"]; + } else { + process.env["AO_OBSERVABILITY_STDERR"] = originalObservabilityStderr; + } + } + }); + it("redacts sensitive observability payload fields before persisting them", () => { const observer = createProjectObserver(config, "session-manager"); diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 690a7bbeb..a66bf3744 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -348,6 +348,68 @@ describe("loadBuiltins", () => { }); }); + it("passes configPath for named notifier references without explicit config", async () => { + const registry = createPluginRegistry(); + const fakeDashboard = makePlugin("notifier", "dashboard"); + const cfg = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + defaults: { + runtime: "tmux", + agent: "codex", + workspace: "worktree", + notifiers: ["dashboard"], + }, + notificationRouting: { + urgent: ["dashboard"], + action: [], + warning: [], + info: [], + }, + notifiers: {}, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeDashboard.create).toHaveBeenCalledWith({ + configPath: "/test/config.yaml", + }); + expect( + registry.get<{ _config: Record }>("notifier", "dashboard")?._config, + ).toEqual({ + configPath: "/test/config.yaml", + }); + }); + + it("does not create an implicit notifier registration over a conflicting explicit entry", async () => { + const registry = createPluginRegistry(); + const fakeDashboard = makePlugin("notifier", "dashboard"); + const cfg = makeOrchestratorConfig({ + defaults: { + runtime: "tmux", + agent: "codex", + workspace: "worktree", + notifiers: ["dashboard"], + }, + notifiers: { + dashboard: { + plugin: "webhook", + url: "https://hooks.example.com/dashboard", + }, + }, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-notifier-dashboard") return fakeDashboard; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeDashboard.create).not.toHaveBeenCalled(); + expect(registry.get("notifier", "dashboard")).toBeNull(); + }); + it("strips package loading metadata from notifier config", async () => { const registry = createPluginRegistry(); const fakeWebhook = makePlugin("notifier", "webhook"); @@ -427,7 +489,7 @@ describe("loadBuiltins", () => { throw new Error(`Not found: ${pkg}`); }); - expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined); + expect(fakeOpenClaw.create).not.toHaveBeenCalled(); expect(fakeWebhook.create).toHaveBeenCalledWith({ url: "http://127.0.0.1:8787/hook", retries: 3, diff --git a/packages/core/src/activity-events.ts b/packages/core/src/activity-events.ts index d7a9313d9..323862caf 100644 --- a/packages/core/src/activity-events.ts +++ b/packages/core/src/activity-events.ts @@ -91,6 +91,9 @@ 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" // Config/plugin-registry/storage migration diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 0bf552192..145a7b2cb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -199,6 +199,13 @@ const NotifierConfigSchema = z .passthrough() .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "Notifier")); +const ObservabilityConfigSchema = z + .object({ + logLevel: z.enum(["debug", "info", "warn", "error"]).default("warn"), + stderr: z.boolean().default(false), + }) + .default({}); + const AgentPermissionSchema = z .enum(["permissionless", "default", "auto-edit", "suggest", "skip"]) .default("permissionless") @@ -355,6 +362,7 @@ const OrchestratorConfigSchema = z.object({ readyThresholdMs: z.number().int().nonnegative().default(300_000), power: PowerConfigSchema, lifecycle: LifecycleConfigSchema, + observability: ObservabilityConfigSchema, defaults: DefaultPluginsSchema.default({}), plugins: z.array(InstalledPluginConfigSchema).default([]), dashboard: DashboardConfigSchema.optional(), @@ -845,6 +853,7 @@ function buildEffectiveConfigFromFlatLocalPath( terminalPort: globalConfig.terminalPort, directTerminalPort: globalConfig.directTerminalPort, readyThresholdMs: globalConfig.readyThresholdMs, + observability: globalConfig.observability, defaults: globalConfig.defaults, notifiers: globalConfig.notifiers, notificationRouting: globalConfig.notificationRouting, @@ -896,6 +905,7 @@ function buildEffectiveConfigFromGlobalConfigPath(configPath: string): LoadedCon terminalPort: globalConfig.terminalPort, directTerminalPort: globalConfig.directTerminalPort, readyThresholdMs: globalConfig.readyThresholdMs, + observability: globalConfig.observability, defaults: globalConfig.defaults, notifiers: globalConfig.notifiers, notificationRouting: globalConfig.notificationRouting, diff --git a/packages/core/src/dashboard-notifications.ts b/packages/core/src/dashboard-notifications.ts new file mode 100644 index 000000000..bf6a9f4e4 --- /dev/null +++ b/packages/core/src/dashboard-notifications.ts @@ -0,0 +1,198 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { NotifyAction, OrchestratorEvent } from "./types.js"; +import type { NotificationDataV3 } from "./notification-data.js"; +import { atomicWriteFileSync } from "./atomic-write.js"; +import { getObservabilityBaseDir } from "./paths.js"; + +export const DEFAULT_DASHBOARD_NOTIFICATION_LIMIT = 50; +export const MAX_DASHBOARD_NOTIFICATION_LIMIT = 500; + +export interface LegacyDashboardNotificationData { + [key: string]: unknown; +} + +export type DashboardNotificationEventData = NotificationDataV3 | LegacyDashboardNotificationData; + +export interface SerializedDashboardEvent { + id: string; + type: string; + priority: string; + sessionId: string; + projectId: string; + timestamp: string; + message: string; + data: DashboardNotificationEventData; +} + +export interface SerializedDashboardAction { + label: string; + url?: string; + callbackEndpoint?: string; +} + +export interface DashboardNotificationRecord { + id: string; + receivedAt: string; + event: SerializedDashboardEvent; + actions?: SerializedDashboardAction[]; +} + +export interface AppendDashboardNotificationOptions { + limit?: unknown; + receivedAt?: Date; +} + +export function normalizeDashboardNotificationLimit(value: unknown): number { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number.parseInt(value, 10) + : DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + + if (!Number.isFinite(parsed)) return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + return Math.min(MAX_DASHBOARD_NOTIFICATION_LIMIT, Math.max(1, Math.floor(parsed))); +} + +export function getDashboardNotificationStorePath(configPath: string): string { + return join(getObservabilityBaseDir(configPath), "dashboard-notifications.jsonl"); +} + +function toJsonRecord(value: unknown): DashboardNotificationEventData { + try { + const serialized = JSON.parse(JSON.stringify(value ?? {})) as unknown; + if (serialized && typeof serialized === "object" && !Array.isArray(serialized)) { + return serialized as DashboardNotificationEventData; + } + } catch { + // Fall through to a small marker below. Notifications should not fail + // because one event payload contained a non-serializable value. + } + + return { serializationError: "event data could not be serialized" }; +} + +function serializeAction(action: NotifyAction): SerializedDashboardAction { + return { + label: action.label, + ...(typeof action.url === "string" ? { url: action.url } : {}), + ...(typeof action.callbackEndpoint === "string" + ? { callbackEndpoint: action.callbackEndpoint } + : {}), + }; +} + +export function createDashboardNotificationRecord( + event: OrchestratorEvent, + actions?: NotifyAction[], + receivedAt = new Date(), +): DashboardNotificationRecord { + const receivedAtIso = receivedAt.toISOString(); + return { + id: `${event.id}:${receivedAtIso}`, + receivedAt: receivedAtIso, + event: { + id: event.id, + type: event.type, + priority: event.priority, + sessionId: event.sessionId, + projectId: event.projectId, + timestamp: event.timestamp.toISOString(), + message: event.message, + data: toJsonRecord(event.data), + }, + ...(actions && actions.length > 0 ? { actions: actions.map(serializeAction) } : {}), + }; +} + +function isDashboardNotificationRecord(value: unknown): value is DashboardNotificationRecord { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + const event = candidate.event as Partial | undefined; + return ( + typeof candidate.id === "string" && + typeof candidate.receivedAt === "string" && + event !== undefined && + typeof event.id === "string" && + typeof event.type === "string" && + typeof event.priority === "string" && + typeof event.sessionId === "string" && + typeof event.projectId === "string" && + typeof event.timestamp === "string" && + typeof event.message === "string" && + event.data !== undefined && + typeof event.data === "object" && + !Array.isArray(event.data) + ); +} + +export function readDashboardNotificationsFromFile( + filePath: string, + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): DashboardNotificationRecord[] { + if (!existsSync(filePath)) return []; + + const normalizedLimit = normalizeDashboardNotificationLimit(limit); + const content = readFileSync(filePath, "utf-8"); + const records: DashboardNotificationRecord[] = []; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (isDashboardNotificationRecord(parsed)) { + records.push(parsed); + } + } catch { + // Ignore malformed lines. A partial write should not break the dashboard. + } + } + + return records.slice(-normalizedLimit); +} + +export function readDashboardNotifications( + configPath: string, + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): DashboardNotificationRecord[] { + return readDashboardNotificationsFromFile(getDashboardNotificationStorePath(configPath), limit); +} + +export function writeDashboardNotificationsToFile( + filePath: string, + records: DashboardNotificationRecord[], + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): void { + const normalizedLimit = normalizeDashboardNotificationLimit(limit); + const retained = records.slice(-normalizedLimit); + mkdirSync(dirname(filePath), { recursive: true }); + const content = + retained.length > 0 ? `${retained.map((record) => JSON.stringify(record)).join("\n")}\n` : ""; + atomicWriteFileSync(filePath, content); +} + +export function appendDashboardNotificationRecord( + filePath: string, + record: DashboardNotificationRecord, + limit: unknown = DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, +): DashboardNotificationRecord { + const existing = readDashboardNotificationsFromFile(filePath, limit); + writeDashboardNotificationsToFile(filePath, [...existing, record], limit); + return record; +} + +export function appendDashboardNotification( + configPath: string, + event: OrchestratorEvent, + actions?: NotifyAction[], + options: AppendDashboardNotificationOptions = {}, +): DashboardNotificationRecord { + const record = createDashboardNotificationRecord(event, actions, options.receivedAt); + return appendDashboardNotificationRecord( + getDashboardNotificationStorePath(configPath), + record, + options.limit, + ); +} diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index fd9678d5b..f5840cc39 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -206,6 +206,13 @@ export const GlobalConfigSchema = z updateChannel: UpdateChannelSchema.optional().catch(undefined), /** Override path-based install detection. Optional. */ installMethod: InstallMethodOverrideSchema.optional().catch(undefined), + /** Structured observability defaults. Env vars still override at runtime. */ + observability: z + .object({ + logLevel: z.enum(["debug", "info", "warn", "error"]).default("warn"), + stderr: z.boolean().default(false), + }) + .optional(), /** Cross-project defaults — projects inherit when fields are omitted. */ defaults: z .object({ @@ -1078,6 +1085,8 @@ export function migrateToGlobalConfig(oldConfigPath: string, globalConfigPath?: newGlobal.directTerminalPort = parsed["directTerminalPort"] as number; if (parsed["readyThresholdMs"] !== null && parsed["readyThresholdMs"] !== undefined) newGlobal.readyThresholdMs = parsed["readyThresholdMs"] as number; + if (parsed["observability"] !== null && parsed["observability"] !== undefined) + newGlobal.observability = parsed["observability"] as GlobalConfig["observability"]; if (parsed["defaults"] !== null && parsed["defaults"] !== undefined) newGlobal.defaults = parsed["defaults"] as GlobalConfig["defaults"]; if (parsed["notifiers"] !== null && parsed["notifiers"] !== undefined) @@ -1168,6 +1177,10 @@ export function createDefaultGlobalConfig(): GlobalConfig { return { port: 3000, readyThresholdMs: 300_000, + observability: { + logLevel: "warn", + stderr: false, + }, defaults: { runtime: getDefaultRuntime(), agent: "claude-code", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 047bf10d5..a8a7f228e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -259,6 +259,43 @@ export { } from "./observability.js"; export { execGhObserved, getGhTraceFilePath } from "./gh-trace.js"; export { resolveNotifierTarget } from "./notifier-resolution.js"; +export { + recordNotificationDelivery, + sanitizeNotificationDeliveryReason, +} from "./notification-observability.js"; +export { + NOTIFICATION_DATA_SCHEMA_VERSION, + buildCIFailureNotificationData, + buildNotificationSubject, + buildPRStateNotificationData, + buildReactionEscalationNotificationData, + buildReactionNotificationData, + buildSessionTransitionNotificationData, + getNotificationDataV3, + semanticTypeForReactionKey, +} from "./notification-data.js"; +export type { + CIFailureNotificationInput, + NotificationCI, + NotificationCICheck, + NotificationDataBaseInput, + NotificationDataV3, + NotificationEscalation, + NotificationEventContext, + NotificationIssueSubject, + NotificationMerge, + NotificationPRContext, + NotificationPRSubject, + NotificationReaction, + NotificationReview, + NotificationSessionSubject, + NotificationSubject, + NotificationTransition, + PRStateNotificationInput, + ReactionEscalationNotificationInput, + ReactionNotificationInput, + SessionTransitionNotificationInput, +} from "./notification-data.js"; export type { ObservabilityLevel, ObservabilityMetricName, @@ -267,6 +304,12 @@ export type { ProjectObserver, } from "./observability.js"; export type { GhTraceContext, GhTraceEntry } from "./gh-trace.js"; +export type { + NotificationDeliveryFailureKind, + NotificationDeliveryMethod, + NotificationDeliveryTarget, + RecordNotificationDeliveryInput, +} from "./notification-observability.js"; // Feedback tools — contracts, validation, and report storage export { @@ -331,6 +374,24 @@ export { export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js"; +export { + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + MAX_DASHBOARD_NOTIFICATION_LIMIT, + appendDashboardNotification, + appendDashboardNotificationRecord, + createDashboardNotificationRecord, + getDashboardNotificationStorePath, + normalizeDashboardNotificationLimit, + readDashboardNotifications, + readDashboardNotificationsFromFile, + writeDashboardNotificationsToFile, + type DashboardNotificationEventData, + type DashboardNotificationRecord, + type LegacyDashboardNotificationData, + type SerializedDashboardAction, + type SerializedDashboardEvent, +} from "./dashboard-notifications.js"; + // Global config — Option C hybrid architecture (global registry + local behavior) export { getGlobalConfigPath, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index e4193ecbf..9fa1a928f 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -69,6 +69,7 @@ import { } from "./report-watcher.js"; import { createCorrelationId, createProjectObserver } from "./observability.js"; import { resolveNotifierTarget } from "./notifier-resolution.js"; +import { recordNotificationDelivery } from "./notification-observability.js"; import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; import { DETECTING_MAX_ATTEMPTS, @@ -80,6 +81,14 @@ import { resolveProbeDecision, type LifecycleDecision, } from "./lifecycle-status-decisions.js"; +import { + buildCIFailureNotificationData, + buildPRStateNotificationData, + buildReactionEscalationNotificationData, + buildReactionNotificationData, + buildSessionTransitionNotificationData, + type NotificationEventContext, +} from "./notification-data.js"; /** Parse a duration string like "10m", "30s", "1h" to milliseconds. */ function parseDuration(str: string): number { @@ -286,23 +295,7 @@ function prStateToEventType( } /** PR context for event enrichment. */ -interface EventPRContext { - url: string; - /** Actual PR title from enrichment cache. null until cache is populated. */ - title: string | null; - number: number; - branch: string; -} - -/** Event context with PR and issue information for webhook payloads. */ -interface EventContext { - pr: EventPRContext | null; - issueId: string | null; - issueTitle: string | null; - /** Agent task summary (NOT the PR title). May describe the work before a PR exists. */ - summary: string | null; - branch: string | null; -} +type EventContext = NotificationEventContext; /** * Minimal session context required for reaction execution and event enrichment. @@ -327,7 +320,7 @@ function buildEventContext( session: Session | ReactionSessionContext, prEnrichmentCache: Map, ): EventContext { - let pr: EventPRContext | null = null; + let pr: EventContext["pr"] = null; if (session.pr) { const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; @@ -338,6 +331,10 @@ function buildEventContext( title: cached?.title ?? null, number: session.pr.number, branch: session.pr.branch, + baseBranch: session.pr.baseBranch, + owner: session.pr.owner, + repo: session.pr.repo, + isDraft: session.pr.isDraft, }; } @@ -510,6 +507,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan */ const prEnrichmentCache = new Map(); + function getPREnrichmentForSession( + session: Session | ReactionSessionContext, + ): PREnrichmentData | undefined { + if (!session.pr) return undefined; + return prEnrichmentCache.get(`${session.pr.owner}/${session.pr.repo}#${session.pr.number}`); + } + /** Repos where Guard 1 returned 304 in the current poll — safe to skip detectPR. */ let prListUnchangedRepos = new Set(); @@ -671,7 +675,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // When Guard 1 returned 304, the repo is in prListUnchangedRepos — no new PRs exist. for (const session of sessions) { if (!session.branch) continue; - if (session.metadata["prAutoDetect"] === "off" || session.metadata["prAutoDetect"] === "false") continue; + if ( + session.metadata["prAutoDetect"] === "off" || + session.metadata["prAutoDetect"] === "false" + ) + continue; if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) continue; if ( @@ -1316,16 +1324,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan ) { const mapped = mapAgentReportToLifecycle(agentReport.state); return commit({ - status: deriveLegacyStatus( - { - ...lifecycle, - session: { - ...lifecycle.session, - state: mapped.sessionState, - reason: mapped.sessionReason, - }, + status: deriveLegacyStatus({ + ...lifecycle, + session: { + ...lifecycle.session, + state: mapped.sessionState, + reason: mapped.sessionReason, }, - ), + }), evidence: `agent_report:${agentReport.state}`, detecting: { attempts: 0 }, sessionState: mapped.sessionState, @@ -1455,6 +1461,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan : typeof escalateAfter === "number" && tracker.attempts > escalateAfter ? "max_attempts" : "max_duration"; + const durationMs = Date.now() - tracker.firstTriggered.getTime(); recordActivityEvent({ projectId, sessionId, @@ -1465,7 +1472,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan data: { reactionKey, attempts: tracker.attempts, - durationSinceFirstMs: Date.now() - tracker.firstTriggered.getTime(), + durationSinceFirstMs: durationMs, escalationCause, }, }); @@ -1475,7 +1482,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId, projectId, message: `Reaction '${reactionKey}' escalated after ${tracker.attempts} attempts`, - data: { reactionKey, attempts: tracker.attempts, context, schemaVersion: 2 }, + data: buildReactionEscalationNotificationData({ + eventType: "reaction.escalated", + sessionId, + projectId, + context, + reactionKey, + action: "escalated", + attempts: tracker.attempts, + cause: escalationCause, + durationMs, + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, reactionConfig.priority ?? "urgent"); @@ -1545,8 +1563,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const event = createEvent("reaction.triggered", { sessionId, projectId, - message: `Reaction '${reactionKey}' triggered notification`, - data: { reactionKey, context, schemaVersion: 2 }, + message: reactionConfig.message ?? `Reaction '${reactionKey}' triggered notification`, + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId, + projectId, + context, + reactionKey, + action: "notify", + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, reactionConfig.priority ?? "info"); recordActivityEvent({ @@ -1572,8 +1598,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const event = createEvent("reaction.triggered", { sessionId, projectId, - message: `Reaction '${reactionKey}' triggered auto-merge`, - data: { reactionKey, context, schemaVersion: 2 }, + message: reactionConfig.message ?? `Reaction '${reactionKey}' triggered auto-merge`, + data: buildReactionNotificationData({ + eventType: "reaction.triggered", + sessionId, + projectId, + context, + reactionKey, + action: "auto-merge", + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, "action"); recordActivityEvent({ @@ -1623,9 +1657,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (!project) return; const sessionsDir = getProjectSessionsDir(session.projectId); - const lifecycleUpdates = buildLifecycleMetadataPatch( - cloneLifecycle(session.lifecycle), - ); + const lifecycleUpdates = buildLifecycleMetadataPatch(cloneLifecycle(session.lifecycle)); const mergedUpdates = { ...updates, ...lifecycleUpdates }; updateMetadata(sessionsDir, session.id, mergedUpdates); sessionManager.invalidateCache(); @@ -2117,7 +2149,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId: session.id, projectId: session.projectId, message: detailedMessage, - data: { failedChecks: failedChecks.map((c) => c.name), context, schemaVersion: 2 }, + data: buildCIFailureNotificationData({ + sessionId: session.id, + projectId: session.projectId, + context, + failedChecks, + }), }); await notifyHuman(event, reactionConfig.priority ?? "warning"); } @@ -2243,12 +2280,40 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const notifier = registry.get("notifier", target.reference) ?? registry.get("notifier", target.pluginName); - if (notifier) { - try { - await notifier.notify(eventWithPriority); - } catch { - // Notifier failed — not much we can do - } + if (!notifier) { + recordNotificationDelivery({ + observer, + event: eventWithPriority, + target, + outcome: "failure", + method: "notify", + reason: "notifier target not found", + failureKind: "target_missing", + recordActivityEvent: true, + }); + continue; + } + + try { + await notifier.notify(eventWithPriority); + recordNotificationDelivery({ + observer, + event: eventWithPriority, + target, + outcome: "success", + method: "notify", + }); + } catch (err) { + recordNotificationDelivery({ + observer, + event: eventWithPriority, + target, + outcome: "failure", + method: "notify", + reason: err instanceof Error ? err.message : String(err), + failureKind: "delivery_failed", + recordActivityEvent: true, + }); } } } @@ -2310,9 +2375,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan activity, // Elapsed wall-time since cleanup was first deferred. NOT a Unix // timestamp — naming it `pendingSinceMs` was misleading (Greptile). - pendingElapsedMs: Number.isFinite(pendingSinceMs) - ? Date.now() - pendingSinceMs - : null, + pendingElapsedMs: Number.isFinite(pendingSinceMs) ? Date.now() - pendingSinceMs : null, graceMs, }, }); @@ -2608,7 +2671,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId: session.id, projectId: session.projectId, message: `${session.id}: ${oldStatus} → ${newStatus}`, - data: { oldStatus, newStatus, context, schemaVersion: 2 }, + data: buildSessionTransitionNotificationData({ + eventType, + sessionId: session.id, + projectId: session.projectId, + context, + oldStatus, + newStatus, + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(event, priority); } @@ -2661,15 +2732,15 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan sessionId: session.id, projectId: session.projectId, message: `${session.id}: PR ${previousPRState} → ${session.lifecycle.pr.state}`, - data: { + data: buildPRStateNotificationData({ + eventType: prEventType, + sessionId: session.id, + projectId: session.projectId, + context, oldPRState: previousPRState, newPRState: session.lifecycle.pr.state, - // prNumber/prUrl kept for backward compat — drop in schemaVersion 3 - prNumber: session.lifecycle.pr.number, - prUrl: session.lifecycle.pr.url, - context, - schemaVersion: 2, - }, + enrichment: getPREnrichmentForSession(session), + }), }); await notifyHuman(prEvent, inferPriority(prEventType)); } diff --git a/packages/core/src/notification-data.ts b/packages/core/src/notification-data.ts new file mode 100644 index 000000000..59e8d84c2 --- /dev/null +++ b/packages/core/src/notification-data.ts @@ -0,0 +1,391 @@ +import type { + CICheck, + CIStatus, + EventType, + PREnrichmentData, + ReviewDecision, + SessionId, + SessionStatus, +} from "./types.js"; + +export const NOTIFICATION_DATA_SCHEMA_VERSION = 3; + +export interface NotificationPRContext { + url: string; + title: string | null; + number: number; + branch: string; + baseBranch?: string; + owner?: string; + repo?: string; + isDraft?: boolean; +} + +export interface NotificationEventContext { + pr: NotificationPRContext | null; + issueId: string | null; + issueTitle: string | null; + summary: string | null; + branch: string | null; +} + +export interface NotificationSessionSubject { + id: SessionId; + projectId: string; +} + +export interface NotificationPRSubject { + number: number; + url: string; + branch?: string; + title?: string; + baseBranch?: string; + owner?: string; + repo?: string; + isDraft?: boolean; +} + +export interface NotificationIssueSubject { + id: string; + title?: string; +} + +export interface NotificationSubject { + session: NotificationSessionSubject; + pr?: NotificationPRSubject; + issue?: NotificationIssueSubject; + summary?: string; + branch?: string; +} + +export interface NotificationTransition { + kind: "session_status" | "pr_state"; + from: string; + to: string; +} + +export interface NotificationCICheck { + name: string; + status: CICheck["status"]; + conclusion?: string; + url?: string; +} + +export interface NotificationCI { + status: CIStatus; + failedChecks?: NotificationCICheck[]; +} + +export interface NotificationReview { + decision?: ReviewDecision; + unresolvedThreads?: number; + url?: string; +} + +export interface NotificationMerge { + ready?: boolean; + conflicts?: boolean; + baseBranch?: string; + isBehind?: boolean; + blockers?: string[]; +} + +export interface NotificationReaction { + key: string; + action: "notify" | "send-to-agent" | "auto-merge" | "escalated"; +} + +export interface NotificationEscalation { + attempts: number; + cause: "max_retries" | "max_attempts" | "max_duration"; + durationMs?: number; +} + +export interface NotificationDataV3 { + [key: string]: unknown; + schemaVersion: typeof NOTIFICATION_DATA_SCHEMA_VERSION; + semanticType?: string; + subject: NotificationSubject; + transition?: NotificationTransition; + ci?: NotificationCI; + review?: NotificationReview; + merge?: NotificationMerge; + reaction?: NotificationReaction; + escalation?: NotificationEscalation; +} + +export interface NotificationDataBaseInput { + sessionId: SessionId; + projectId: string; + context: NotificationEventContext; + semanticType?: string; +} + +export interface SessionTransitionNotificationInput extends NotificationDataBaseInput { + eventType: EventType; + oldStatus: SessionStatus; + newStatus: SessionStatus; + enrichment?: PREnrichmentData; +} + +export interface PRStateNotificationInput extends NotificationDataBaseInput { + eventType: EventType; + oldPRState: string; + newPRState: string; + enrichment?: PREnrichmentData; +} + +export interface CIFailureNotificationInput extends NotificationDataBaseInput { + failedChecks: CICheck[]; +} + +export interface ReactionNotificationInput extends NotificationDataBaseInput { + eventType: "reaction.triggered" | "reaction.escalated"; + reactionKey: string; + action: NotificationReaction["action"]; + enrichment?: PREnrichmentData; +} + +export interface ReactionEscalationNotificationInput extends ReactionNotificationInput { + attempts: number; + cause: NotificationEscalation["cause"]; + durationMs?: number; +} + +const REACTION_SEMANTIC_TYPES: Record = { + "pr-closed": "pr.closed", + "ci-failed": "ci.failing", + "changes-requested": "review.changes_requested", + "bugbot-comments": "automated_review.found", + "merge-conflicts": "merge.conflicts", + "approved-and-green": "merge.ready", + "agent-stuck": "session.stuck", + "agent-needs-input": "session.needs_input", + "agent-exited": "session.killed", + "all-complete": "summary.all_complete", + "report-no-acknowledge": "report.no_acknowledge", + "report-stale": "report.stale", + "report-needs-input": "report.needs_input", +}; + +function maybeString(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function serializeCheck(check: CICheck): NotificationCICheck { + const conclusion = maybeString(check.conclusion); + const url = maybeString(check.url); + return { + name: check.name, + status: check.status, + ...(conclusion ? { conclusion } : {}), + ...(url ? { url } : {}), + }; +} + +export function semanticTypeForReactionKey( + reactionKey: string, + fallback: EventType | string, +): string { + return REACTION_SEMANTIC_TYPES[reactionKey] ?? fallback; +} + +export function buildNotificationSubject(input: NotificationDataBaseInput): NotificationSubject { + const { context, sessionId, projectId } = input; + const subject: NotificationSubject = { + session: { id: sessionId, projectId }, + }; + + if (context.pr) { + const branch = maybeString(context.pr.branch); + const title = maybeString(context.pr.title); + const baseBranch = maybeString(context.pr.baseBranch); + const owner = maybeString(context.pr.owner); + const repo = maybeString(context.pr.repo); + + subject.pr = { + number: context.pr.number, + url: context.pr.url, + ...(branch ? { branch } : {}), + ...(title ? { title } : {}), + ...(baseBranch ? { baseBranch } : {}), + ...(owner ? { owner } : {}), + ...(repo ? { repo } : {}), + ...(typeof context.pr.isDraft === "boolean" ? { isDraft: context.pr.isDraft } : {}), + }; + } + + const issueId = maybeString(context.issueId); + if (issueId) { + const issueTitle = maybeString(context.issueTitle); + subject.issue = { + id: issueId, + ...(issueTitle ? { title: issueTitle } : {}), + }; + } + + const summary = maybeString(context.summary); + if (summary) subject.summary = summary; + const contextBranch = maybeString(context.branch); + if (contextBranch) subject.branch = contextBranch; + + return subject; +} + +function createBaseNotificationData(input: NotificationDataBaseInput): NotificationDataV3 { + return { + schemaVersion: NOTIFICATION_DATA_SCHEMA_VERSION, + ...(maybeString(input.semanticType) ? { semanticType: input.semanticType } : {}), + subject: buildNotificationSubject(input), + }; +} + +function addEnrichment(data: NotificationDataV3, enrichment: PREnrichmentData | undefined): void { + if (!enrichment) return; + + if (enrichment.ciStatus !== "none") { + data.ci = { ...(data.ci ?? {}), status: data.ci?.status ?? enrichment.ciStatus }; + } + + if (enrichment.reviewDecision !== "none") { + data.review = { + ...(data.review ?? {}), + decision: data.review?.decision ?? enrichment.reviewDecision, + }; + } + + if ( + enrichment.mergeable || + typeof enrichment.hasConflicts === "boolean" || + typeof enrichment.isBehind === "boolean" || + (enrichment.blockers?.length ?? 0) > 0 + ) { + data.merge = { + ...(data.merge ?? {}), + ready: data.merge?.ready ?? enrichment.mergeable, + ...(typeof enrichment.hasConflicts === "boolean" + ? { conflicts: data.merge?.conflicts ?? enrichment.hasConflicts } + : {}), + ...(typeof enrichment.isBehind === "boolean" ? { isBehind: enrichment.isBehind } : {}), + ...(enrichment.blockers && enrichment.blockers.length > 0 + ? { blockers: enrichment.blockers } + : {}), + }; + } +} + +function addSemanticDomain( + data: NotificationDataV3, + semanticType: string, + enrichment: PREnrichmentData | undefined, +): void { + switch (semanticType) { + case "ci.failing": + data.ci = { ...(data.ci ?? {}), status: "failing" }; + break; + case "review.pending": + data.review = { ...(data.review ?? {}), decision: "pending" }; + break; + case "review.approved": + data.review = { ...(data.review ?? {}), decision: "approved" }; + break; + case "review.changes_requested": + data.review = { ...(data.review ?? {}), decision: "changes_requested" }; + break; + case "merge.ready": + data.merge = { + ...(data.merge ?? {}), + ready: true, + conflicts: enrichment?.hasConflicts ?? false, + ...(data.subject.pr?.baseBranch ? { baseBranch: data.subject.pr.baseBranch } : {}), + }; + break; + case "merge.conflicts": + data.merge = { + ...(data.merge ?? {}), + ready: false, + conflicts: true, + ...(data.subject.pr?.baseBranch ? { baseBranch: data.subject.pr.baseBranch } : {}), + }; + break; + default: + break; + } +} + +export function buildSessionTransitionNotificationData( + input: SessionTransitionNotificationInput, +): NotificationDataV3 { + const data = createBaseNotificationData({ ...input, semanticType: input.eventType }); + data.transition = { + kind: "session_status", + from: input.oldStatus, + to: input.newStatus, + }; + addEnrichment(data, input.enrichment); + addSemanticDomain(data, input.eventType, input.enrichment); + return data; +} + +export function buildPRStateNotificationData(input: PRStateNotificationInput): NotificationDataV3 { + const data = createBaseNotificationData({ ...input, semanticType: input.eventType }); + data.transition = { + kind: "pr_state", + from: input.oldPRState, + to: input.newPRState, + }; + addEnrichment(data, input.enrichment); + addSemanticDomain(data, input.eventType, input.enrichment); + return data; +} + +export function buildCIFailureNotificationData( + input: CIFailureNotificationInput, +): NotificationDataV3 { + const data = createBaseNotificationData({ ...input, semanticType: "ci.failing" }); + data.ci = { + status: "failing", + failedChecks: input.failedChecks.map(serializeCheck), + }; + return data; +} + +export function buildReactionNotificationData( + input: ReactionNotificationInput, +): NotificationDataV3 { + const semanticType = semanticTypeForReactionKey(input.reactionKey, input.eventType); + const data = createBaseNotificationData({ ...input, semanticType }); + data.reaction = { + key: input.reactionKey, + action: input.action, + }; + addEnrichment(data, input.enrichment); + addSemanticDomain(data, semanticType, input.enrichment); + return data; +} + +export function buildReactionEscalationNotificationData( + input: ReactionEscalationNotificationInput, +): NotificationDataV3 { + const data = buildReactionNotificationData(input); + data.escalation = { + attempts: input.attempts, + cause: input.cause, + ...(typeof input.durationMs === "number" ? { durationMs: input.durationMs } : {}), + }; + return data; +} + +export function getNotificationDataV3(data: unknown): NotificationDataV3 | null { + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const candidate = data as Partial; + if (candidate.schemaVersion !== NOTIFICATION_DATA_SCHEMA_VERSION) return null; + if ( + !candidate.subject || + typeof candidate.subject !== "object" || + Array.isArray(candidate.subject) + ) { + return null; + } + return candidate as NotificationDataV3; +} diff --git a/packages/core/src/notification-observability.ts b/packages/core/src/notification-observability.ts new file mode 100644 index 000000000..8601b895a --- /dev/null +++ b/packages/core/src/notification-observability.ts @@ -0,0 +1,184 @@ +import { recordActivityEvent, type ActivityEventKind } from "./activity-events.js"; +import { createCorrelationId, type ProjectObserver } from "./observability.js"; +import type { OrchestratorEvent } from "./types.js"; + +export type NotificationDeliveryMethod = "notify" | "notifyWithActions"; +export type NotificationDeliveryFailureKind = "delivery_failed" | "target_missing"; + +export interface NotificationDeliveryTarget { + reference: string; + pluginName: string; +} + +export interface RecordNotificationDeliveryInput { + observer: ProjectObserver; + event: OrchestratorEvent; + target: NotificationDeliveryTarget; + outcome: "success" | "failure"; + method?: NotificationDeliveryMethod; + reason?: string; + failureKind?: NotificationDeliveryFailureKind; + recordActivityEvent?: boolean; +} + +const TOKEN_PATTERNS: ReadonlyArray = [ + [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/gi, "Bearer [redacted]"], + [/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[redacted]"], + [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "[redacted]"], + [/\bsk-(?:ant-)?(?:proj-|svcacct-)?[A-Za-z0-9_-]{16,}\b/g, "[redacted]"], + [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, "[redacted]"], + [/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]"], + [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]"], + [ + /\b([A-Z][A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|AUTHORIZATION|COOKIE|API_KEY|APIKEY)[A-Z0-9_]*)=([^\s"'`]{6,})/g, + "$1=[redacted]", + ], +]; + +function redactCredentialUrls(input: string): string { + let result = input; + let offset = 0; + while (offset < result.length) { + const proto = result.indexOf("://", offset); + if (proto === -1) break; + if (proto < 4) { + offset = proto + 3; + continue; + } + + const schemeEnd = result.slice(Math.max(0, proto - 5), proto).toLowerCase(); + if (!schemeEnd.endsWith("http") && !schemeEnd.endsWith("https")) { + offset = proto + 3; + continue; + } + + let cursor = proto + 3; + let redacted = false; + while (cursor < result.length) { + const ch = result.charCodeAt(cursor); + if (ch <= 0x20 || ch === 0x2f) break; + if (ch === 0x40) { + result = result.slice(0, proto + 3).toLowerCase() + "[redacted]" + result.slice(cursor); + offset = proto + 3 + "[redacted]".length + 1; + redacted = true; + break; + } + cursor++; + } + if (!redacted) offset = proto + 3; + } + return result; +} + +export function sanitizeNotificationDeliveryReason(reason: string): string { + let cleaned = redactCredentialUrls(reason.replace(/\s+/g, " ").trim()); + for (const [pattern, replacement] of TOKEN_PATTERNS) { + cleaned = cleaned.replace(pattern, replacement); + } + return cleaned.length > 300 ? `${cleaned.slice(0, 297)}...` : cleaned; +} + +function notificationSurface(reference: string): string { + let suffix = ""; + let lastNonHyphenLength = 0; + let previousWasGeneratedHyphen = false; + + for (const ch of reference) { + const code = ch.charCodeAt(0); + const isAlphaNumeric = + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a); + const isAllowed = isAlphaNumeric || ch === "_" || ch === "-"; + + if (!isAllowed) { + if (suffix.length > 0 && !previousWasGeneratedHyphen) { + suffix += "-"; + previousWasGeneratedHyphen = true; + } + continue; + } + + if (ch === "-" && suffix.length === 0) { + continue; + } + + suffix += ch; + previousWasGeneratedHyphen = false; + if (ch !== "-") { + lastNonHyphenLength = suffix.length; + } + } + + suffix = suffix.slice(0, lastNonHyphenLength); + return `notification.delivery.${suffix || "target"}`; +} + +function activityKind(kind: NotificationDeliveryFailureKind): ActivityEventKind { + return kind === "target_missing" ? "notification.target_missing" : "notification.delivery_failed"; +} + +function safeRecordActivityFailure(input: RecordNotificationDeliveryInput, reason: string): void { + if (!input.failureKind) return; + try { + recordActivityEvent({ + projectId: input.event.projectId, + sessionId: input.event.sessionId, + source: "notifier", + kind: activityKind(input.failureKind), + level: "warn", + summary: + input.failureKind === "target_missing" + ? `notification target missing: ${input.target.reference}` + : `notification delivery failed: ${input.target.reference}`, + data: { + eventId: input.event.id, + eventType: input.event.type, + priority: input.event.priority, + targetReference: input.target.reference, + targetPlugin: input.target.pluginName, + deliveryMethod: input.method ?? "notify", + errorMessage: reason, + }, + }); + } catch { + // Activity events are diagnostic-only; notification delivery must not depend on them. + } +} + +export function recordNotificationDelivery(input: RecordNotificationDeliveryInput): void { + const reason = input.reason ? sanitizeNotificationDeliveryReason(input.reason) : undefined; + const data = { + eventId: input.event.id, + eventType: input.event.type, + priority: input.event.priority, + targetReference: input.target.reference, + targetPlugin: input.target.pluginName, + deliveryMethod: input.method ?? "notify", + }; + + input.observer.recordOperation({ + metric: "notification_delivery", + operation: "notification.deliver", + outcome: input.outcome, + correlationId: createCorrelationId("notification"), + projectId: input.event.projectId, + sessionId: input.event.sessionId, + reason, + data, + level: input.outcome === "failure" ? "warn" : "info", + }); + + input.observer.setHealth({ + surface: notificationSurface(input.target.reference), + status: input.outcome === "failure" ? "warn" : "ok", + projectId: input.event.projectId, + correlationId: createCorrelationId("notification-health"), + reason, + details: data, + }); + + if (input.outcome === "failure" && input.recordActivityEvent) { + safeRecordActivityFailure(input, reason ?? "notification delivery failed"); + } +} diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index 15f62efdd..3c9f4eb4f 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -24,6 +24,7 @@ export type ObservabilityMetricName = | "graphql_batch" | "kill" | "lifecycle_poll" + | "notification_delivery" | "restore" | "send" | "spawn" @@ -167,25 +168,43 @@ function sanitizeComponent(component: string): string { return component.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "component"; } -function getLogLevel(): ObservabilityLevel { - const raw = process.env["AO_LOG_LEVEL"]?.trim().toLowerCase(); - if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") { - return raw; +function parseLogLevel(raw: string | undefined): ObservabilityLevel | null { + const value = raw?.trim().toLowerCase(); + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; } - return "warn"; + return null; } -function shouldLog(level: ObservabilityLevel): boolean { - return LEVEL_ORDER[level] >= LEVEL_ORDER[getLogLevel()]; +function parseBooleanEnv(raw: string | undefined): boolean | null { + const value = raw?.trim().toLowerCase(); + if (!value) return null; + if (value === "1" || value === "true" || value === "yes" || value === "on") return true; + if (value === "0" || value === "false" || value === "no" || value === "off") return false; + return null; } -function shouldMirrorStructuredLogsToStderr(): boolean { - const raw = process.env["AO_OBSERVABILITY_STDERR"]?.trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; +function getLogLevel(config: OrchestratorConfig): ObservabilityLevel { + const raw = process.env["AO_LOG_LEVEL"]?.trim().toLowerCase(); + return parseLogLevel(raw) ?? config.observability?.logLevel ?? "warn"; } -function emitStructuredLog(entry: Record, level: ObservabilityLevel): void { - if (!shouldMirrorStructuredLogsToStderr() || !shouldLog(level)) return; +function shouldLog(level: ObservabilityLevel, config: OrchestratorConfig): boolean { + return LEVEL_ORDER[level] >= LEVEL_ORDER[getLogLevel(config)]; +} + +function shouldMirrorStructuredLogsToStderr(config: OrchestratorConfig): boolean { + return ( + parseBooleanEnv(process.env["AO_OBSERVABILITY_STDERR"]) ?? config.observability?.stderr ?? false + ); +} + +function emitStructuredLog( + config: OrchestratorConfig, + entry: Record, + level: ObservabilityLevel, +): void { + if (!shouldMirrorStructuredLogsToStderr(config) || !shouldLog(level, config)) return; process.stderr.write(`${JSON.stringify({ ...entry, level })}\n`); } @@ -424,9 +443,9 @@ export function createProjectObserver( const snapshot = readSnapshot(filePath, normalizedComponent); updater(snapshot); writeSnapshot(config, snapshot); - if (logEntry && shouldLog(logEntry.level)) { + if (logEntry && shouldLog(logEntry.level, config)) { appendAuditLog(config, normalizedComponent, logEntry.payload, logEntry.level); - emitStructuredLog(logEntry.payload, logEntry.level); + emitStructuredLog(config, logEntry.payload, logEntry.level); } } catch (error) { const payload = { @@ -438,7 +457,7 @@ export function createProjectObserver( reason: error instanceof Error ? error.message : String(error), }; appendObservabilityFailure(config, payload); - emitStructuredLog(payload, "error"); + emitStructuredLog(config, payload, "error"); } } diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index a00d1e401..ed8db891a 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -59,6 +59,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "scm", name: "gitlab", pkg: "@aoagents/ao-plugin-scm-gitlab" }, // Notifiers { slot: "notifier", name: "composio", pkg: "@aoagents/ao-plugin-notifier-composio" }, + { slot: "notifier", name: "dashboard", pkg: "@aoagents/ao-plugin-notifier-dashboard" }, { slot: "notifier", name: "desktop", pkg: "@aoagents/ao-plugin-notifier-desktop" }, { slot: "notifier", name: "discord", pkg: "@aoagents/ao-plugin-notifier-discord" }, { slot: "notifier", name: "openclaw", pkg: "@aoagents/ao-plugin-notifier-openclaw" }, @@ -79,6 +80,19 @@ function matchesNotifierPlugin( return hasExplicitPlugin ? configuredPlugin === pluginName : notifierId === pluginName; } +function hasExplicitConflictingNotifierEntry( + pluginName: string, + config: OrchestratorConfig, +): boolean { + if (!Object.prototype.hasOwnProperty.call(config.notifiers ?? {}, pluginName)) return false; + const exactMatch = config.notifiers?.[pluginName]; + return ( + !exactMatch || + typeof exactMatch !== "object" || + !matchesNotifierPlugin(pluginName, pluginName, exactMatch) + ); +} + function collectNotifierRegistrations( pluginName: string, config: OrchestratorConfig, @@ -86,6 +100,14 @@ function collectNotifierRegistrations( ): NotifierRegistration[] { const orderedMatches = new Map>(); const notifierEntries = Object.entries(config.notifiers ?? {}); + const defaultNotifiers = Array.isArray(config.defaults?.notifiers) + ? config.defaults.notifiers + : []; + const routingNotifiers = Object.values(config.notificationRouting ?? {}).flatMap((value) => + Array.isArray(value) ? value : [], + ); + const isReferencedByName = + defaultNotifiers.includes(pluginName) || routingNotifiers.includes(pluginName); const exactMatch = config.notifiers?.[pluginName]; if ( @@ -103,6 +125,14 @@ function collectNotifierRegistrations( } } + if ( + orderedMatches.size === 0 && + isReferencedByName && + !hasExplicitConflictingNotifierEntry(pluginName, config) + ) { + orderedMatches.set(pluginName, {}); + } + return [...orderedMatches.entries()].map(([registrationName, rawConfig]) => ({ registrationName, config: prepareConfig( @@ -143,9 +173,12 @@ function prepareConfig( // Skip the built-in guard for external loads: when loading via `path`, the manifest.name // may legitimately collide with a built-in (e.g. a forked "slack"), and the path field // here IS the loading path, not a stray user config value. - const isBuiltin = !isExternalLoad && BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name); + const isBuiltin = + !isExternalLoad && BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name); if ((rawConfig.package || isBuiltin) && "path" in rawConfig) { - const loadingMethod = rawConfig.package ? `npm package "${rawConfig.package}"` : `built-in plugin "${name}"`; + const loadingMethod = rawConfig.package + ? `npm package "${rawConfig.package}"` + : `built-in plugin "${name}"`; throw new Error( `In ${slot} "${sourceId}": "path" field conflicts with reserved plugin loading field. ` + `You're loading via ${loadingMethod}, but also have a "path" field which would be stripped. ` + @@ -404,6 +437,7 @@ export function createPluginRegistry(): PluginRegistry { const registrations = collectNotifierRegistrations(manifest.name, config, isExternalLoad); if (registrations.length === 0) { + if (hasExplicitConflictingNotifierEntry(manifest.name, config)) return; registerInstance(manifest.slot, manifest.name, manifest, plugin.create(undefined)); return; } @@ -500,7 +534,9 @@ export function createPluginRegistry(): PluginRegistry { const specifier = resolvePluginSpecifier(plugin, config); if (!specifier) { - process.stderr.write(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`); + process.stderr.write( + `[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`, + ); recordActivityEvent({ source: "plugin-registry", kind: "plugin-registry.specifier_failed", @@ -564,7 +600,9 @@ export function createPluginRegistry(): PluginRegistry { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`); + process.stderr.write( + `[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`, + ); recordActivityEvent({ source: "plugin-registry", kind: "plugin-registry.load_failed", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b68deaf2a..66b1ec0ef 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1334,6 +1334,13 @@ export interface LifecycleConfig { mergeCleanupIdleGraceMs: number; } +export interface ObservabilityConfig { + /** Minimum structured log level to persist/mirror. Defaults to "warn". */ + logLevel: ObservabilityLevel; + /** Mirror structured observability logs to stderr. Defaults to false. */ + stderr: boolean; +} + /** Top-level orchestrator configuration (from agent-orchestrator.yaml) */ export interface OrchestratorConfig { /** Optional JSON Schema hint for editor autocomplete/validation. */ @@ -1369,6 +1376,12 @@ export interface OrchestratorConfig { */ lifecycle?: LifecycleConfig; + /** + * Process observability settings. Populated with defaults by Zod when loaded + * from YAML, but optional for hand-constructed tests. + */ + observability?: ObservabilityConfig; + /** Default plugin selections */ defaults: DefaultPlugins; diff --git a/packages/integration-tests/src/notifier-composio.integration.test.ts b/packages/integration-tests/src/notifier-composio.integration.test.ts index 308645a5d..2a47aaecb 100644 --- a/packages/integration-tests/src/notifier-composio.integration.test.ts +++ b/packages/integration-tests/src/notifier-composio.integration.test.ts @@ -9,14 +9,35 @@ import type { NotifyAction } from "@aoagents/ao-core"; import composioPlugin from "@aoagents/ao-plugin-notifier-composio"; import { makeEvent } from "./helpers/event-factory.js"; -const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true }); -const mockClient = { executeAction: mockExecuteAction }; +const mockToolsExecute = vi.fn().mockResolvedValue({ successful: true }); +const mockClient = { tools: { execute: mockToolsExecute } }; + +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} + +function getToolArgs(): Record { + return mockToolsExecute.mock.calls[0][1].arguments; +} + +function getSlackAttachment(): Record { + return JSON.parse(String(getToolArgs().attachments))[0]; +} + +function getSlackActions(): Array> { + return getSlackAttachment().blocks.find((block: any) => block.type === "actions")?.elements ?? []; +} describe("notifier-composio integration", () => { const originalEnv = process.env.COMPOSIO_API_KEY; beforeEach(() => { vi.clearAllMocks(); + mockToolsExecute.mockResolvedValue({ successful: true }); delete process.env.COMPOSIO_API_KEY; }); @@ -29,7 +50,7 @@ describe("notifier-composio integration", () => { }); describe("config -> tool slug routing", () => { - it("slack app routes to SLACK_SEND_MESSAGE with channel", async () => { + it("slack app routes to SLACK_SEND_MESSAGE with normalized channel", async () => { const notifier = composioPlugin.create({ composioApiKey: "key", defaultApp: "slack", @@ -38,53 +59,82 @@ describe("notifier-composio integration", () => { }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", expect.objectContaining({ - action: "SLACK_SEND_MESSAGE", - params: expect.objectContaining({ - channel: "#deploys", + arguments: expect.objectContaining({ + channel: "deploys", }), }), ); }); - it("discord app routes to DISCORD_SEND_MESSAGE with channel_id", async () => { + it("discord app routes to DISCORDBOT_CREATE_MESSAGE with channel_id", async () => { const notifier = composioPlugin.create({ composioApiKey: "key", defaultApp: "discord", + mode: "bot", channelId: "1234567890", _clientOverride: mockClient, }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({ - action: "DISCORD_SEND_MESSAGE", - params: expect.objectContaining({ + arguments: expect.objectContaining({ channel_id: "1234567890", }), }), ); }); - it("gmail app routes to GMAIL_SEND_EMAIL with to/subject/body", async () => { + it("discord webhook mode routes to DISCORDBOT_EXECUTE_WEBHOOK", async () => { const notifier = composioPlugin.create({ composioApiKey: "key", - defaultApp: "gmail", - emailTo: "admin@example.com", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", _clientOverride: mockClient, }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_EXECUTE_WEBHOOK", expect.objectContaining({ - action: "GMAIL_SEND_EMAIL", - params: expect.objectContaining({ - to: "admin@example.com", - subject: "Agent Orchestrator Notification", + arguments: expect.objectContaining({ + webhook_id: "1234567890", + webhook_token: "webhook-token", }), }), ); + expect(mockToolsExecute.mock.calls[0][1]).not.toHaveProperty("connectedAccountId"); + }); + + it("gmail app routes to GMAIL_SEND_EMAIL with recipient/subject/body", async () => { + const notifier = composioPlugin.create({ + composioApiKey: "key", + defaultApp: "gmail", + emailTo: "admin@example.com", + connectedAccountId: "ca_gmail", + _clientOverride: mockClient, + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", + expect.objectContaining({ + connectedAccountId: "ca_gmail", + version: "20260506_01", + arguments: expect.objectContaining({ + recipient_email: "admin@example.com", + subject: "[AO] Session Spawned: app-1", + is_html: true, + }), + }), + ); + expect(getToolArgs().body).toContain(""); + expect(getToolArgs().body).toContain("Session app-1 spawned successfully"); }); }); @@ -98,10 +148,11 @@ describe("notifier-composio integration", () => { makeEvent({ priority: "urgent", type: "ci.failing", sessionId: "app-5" }), ); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; - expect(text).toContain("\u{1F6A8}"); // urgent emoji - expect(text).toContain("ci.failing"); - expect(text).toContain("app-5"); + const attachment = getSlackAttachment(); + expect(attachment.fallback).toContain("Urgent"); + expect(attachment.fallback).toContain("CI failing"); + expect(attachment.blocks[0].text.text).toContain(":rotating_light:"); + expect(attachment.blocks[2].fields[1].text).toContain("app-5"); }); it("includes PR URL when present in event data", async () => { @@ -109,10 +160,25 @@ describe("notifier-composio integration", () => { composioApiKey: "key", _clientOverride: mockClient, }); - await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/99" } })); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { number: 99, url: "https://github.com/org/repo/pull/99" }, + }, + }), + }), + ); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; - expect(text).toContain("https://github.com/org/repo/pull/99"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "View PR" }), + url: "https://github.com/org/repo/pull/99", + }), + ]), + ); }); it("omits PR URL when not a string", async () => { @@ -122,7 +188,7 @@ describe("notifier-composio integration", () => { }); await notifier.notify(makeEvent({ data: { prUrl: 123 } })); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; + const text = mockToolsExecute.mock.calls[0][1].arguments.markdown_text as string; expect(text).not.toContain("PR:"); }); }); @@ -139,9 +205,18 @@ describe("notifier-composio integration", () => { ]; await notifier.notifyWithActions!(makeEvent(), actions); - const text = mockExecuteAction.mock.calls[0][0].params.text as string; - expect(text).toContain("Merge PR: https://github.com/merge"); - expect(text).toContain("- Kill Session"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "Merge PR" }), + url: "https://github.com/merge", + }), + expect.objectContaining({ + text: expect.objectContaining({ text: "Kill Session" }), + value: "/api/kill", + }), + ]), + ); }); }); @@ -154,9 +229,9 @@ describe("notifier-composio integration", () => { }); await notifier.post!("All sessions complete", { channel: "#override" }); - const args = mockExecuteAction.mock.calls[0][0].params; - expect(args.text).toBe("All sessions complete"); - expect(args.channel).toBe("#override"); + const args = mockToolsExecute.mock.calls[0][1].arguments; + expect(args.markdown_text).toBe("All sessions complete"); + expect(args.channel).toBe("override"); }); it("returns null", async () => { @@ -172,10 +247,12 @@ describe("notifier-composio integration", () => { describe("error handling", () => { it("unsuccessful result throws descriptive error", async () => { const failClient = { - executeAction: vi.fn().mockResolvedValue({ - successful: false, - error: "Channel not found", - }), + tools: { + execute: vi.fn().mockResolvedValue({ + successful: false, + error: "Channel not found", + }), + }, }; const notifier = composioPlugin.create({ diff --git a/packages/integration-tests/src/notifier-desktop.integration.test.ts b/packages/integration-tests/src/notifier-desktop.integration.test.ts index 5dcfad0c8..52fe1b075 100644 --- a/packages/integration-tests/src/notifier-desktop.integration.test.ts +++ b/packages/integration-tests/src/notifier-desktop.integration.test.ts @@ -10,9 +10,17 @@ import { makeEvent } from "./helpers/event-factory.js"; vi.mock("node:child_process", () => ({ execFile: vi.fn(), + execFileSync: vi.fn(() => { + throw new Error("not found"); + }), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), })); vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/Users/test"), platform: vi.fn(() => "darwin"), })); @@ -121,8 +129,8 @@ describe("notifier-desktop integration", () => { expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); const args = mockExecFile.mock.calls[0][1] as string[]; - expect(args).toContain("Agent Orchestrator [backend-1]"); - expect(args).toContain("Test msg"); + expect(args).toContain("Session Spawned"); + expect(args.some((arg) => arg.includes("Test msg"))).toBe(true); }); it("linux + urgent -> --urgency=critical before title", async () => { diff --git a/packages/integration-tests/src/notifier-openclaw.integration.test.ts b/packages/integration-tests/src/notifier-openclaw.integration.test.ts index ecf2d88f2..330c085b6 100644 --- a/packages/integration-tests/src/notifier-openclaw.integration.test.ts +++ b/packages/integration-tests/src/notifier-openclaw.integration.test.ts @@ -49,8 +49,12 @@ describe("notifier-openclaw integration", () => { expect(body.sessionKey).toBe("hook:ao:ao-12"); expect(body.wakeMode).toBe("now"); expect(body.deliver).toBe(true); - expect(body.message).toContain("[AO URGENT]"); + expect(body.message).toContain("**AO URGENT** `reaction.escalated`"); expect(body.message).toContain("CI failed 5 times"); + expect(body.message).toContain("- Project: `my-project`"); + expect(body.message).toContain("- Session: `ao-12`"); + expect(body.message).toContain("- attempts: 5"); + expect(body.message).toContain("- reason: ci_failed"); }); it("notifyWithActions formats escalation header/context and appends action labels", async () => { @@ -73,9 +77,14 @@ describe("notifier-openclaw integration", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.sessionKey).toBe("hook:ao:ao-5"); - expect(body.message).toContain("[AO ACTION] ao-5 ci.failing"); - expect(body.message).toContain('Context: {"checkName":"lint"}'); - expect(body.message).toContain("Actions available: retry, kill"); + expect(body.message).toContain("**AO ACTION** `ci.failing`"); + expect(body.message).toContain("CI check failed on app-1"); + expect(body.message).toContain("- Project: `my-project`"); + expect(body.message).toContain("- Session: `ao-5`"); + expect(body.message).toContain("- checkName: lint"); + expect(body.message).toContain("**Actions**"); + expect(body.message).toContain("- retry"); + expect(body.message).toContain("- kill"); }); it("uses explicit deliver=true in hooks payload", async () => { diff --git a/packages/integration-tests/src/notifier-slack.integration.test.ts b/packages/integration-tests/src/notifier-slack.integration.test.ts index e49edabee..7204ccfe7 100644 --- a/packages/integration-tests/src/notifier-slack.integration.test.ts +++ b/packages/integration-tests/src/notifier-slack.integration.test.ts @@ -16,6 +16,29 @@ function mockFetchOk() { }); } +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} + +function getAttachment(body: Record): Record { + return body.attachments[0]; +} + +function getBlocks(body: Record): Array> { + return getAttachment(body).blocks; +} + +function expectDefined(value: T | undefined): asserts value is T { + expect(value).toBeDefined(); + if (value === undefined) { + throw new Error("Expected value to be defined"); + } +} + describe("notifier-slack integration", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -43,61 +66,62 @@ describe("notifier-slack integration", () => { sessionId: "backend-3", projectId: "integrator", message: "CI is failing on backend-3", - data: { - prUrl: "https://github.com/org/repo/pull/42", - ciStatus: "failing", - }, + data: makeV3Data({ + subject: { + session: { id: "backend-3", projectId: "integrator" }, + pr: { number: 42, url: "https://github.com/org/repo/pull/42" }, + }, + ci: { status: "failing" }, + }), }), ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const blocks = getBlocks(body); // Verify full structure expect(body.username).toBe("TestBot"); expect(body.channel).toBe("#deploys"); // Block 0: header - expect(body.blocks[0].type).toBe("header"); - expect(body.blocks[0].text.type).toBe("plain_text"); - expect(body.blocks[0].text.text).toContain(":rotating_light:"); - expect(body.blocks[0].text.text).toContain("session.spawned"); - expect(body.blocks[0].text.text).toContain("backend-3"); - expect(body.blocks[0].text.emoji).toBe(true); + expect(blocks[0].type).toBe("header"); + expect(blocks[0].text.type).toBe("plain_text"); + expect(blocks[0].text.text).toContain(":rotating_light:"); + expect(blocks[0].text.text).toContain("Session Spawned"); + expect(blocks[0].text.emoji).toBe(true); // Block 1: message section - expect(body.blocks[1].type).toBe("section"); - expect(body.blocks[1].text.type).toBe("mrkdwn"); - expect(body.blocks[1].text.text).toBe("CI is failing on backend-3"); + expect(blocks[1].type).toBe("section"); + expect(blocks[1].text.type).toBe("mrkdwn"); + expect(blocks[1].text.text).toBe("CI is failing on backend-3"); - // Block 2: context with project/priority/time - expect(body.blocks[2].type).toBe("context"); - expect(body.blocks[2].elements[0].text).toContain("*Project:* integrator"); - expect(body.blocks[2].elements[0].text).toContain("*Priority:* urgent"); + // Field block includes project/session/priority metadata + expect(blocks[2].type).toBe("section"); + expect(blocks[2].fields[0].text).toContain("*Project*"); + expect(blocks[2].fields[0].text).toContain("integrator"); + expect(blocks[2].fields[2].text).toContain("*Priority*"); + expect(blocks[2].fields[2].text).toContain("Urgent"); - // Block 3: PR link - const prBlock = body.blocks.find( - (b: Record) => - b.type === "section" && - typeof (b as { text?: { text?: string } }).text?.text === "string" && - (b as { text: { text: string } }).text.text.includes("View Pull Request"), - ); - expect(prBlock).toBeDefined(); - expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42"); + // PR link is rendered as an action button + const actionsBlock = blocks.find((b: Record) => b.type === "actions"); + expectDefined(actionsBlock); + expect(actionsBlock.elements[0].text.text).toBe("View PR"); + expect(actionsBlock.elements[0].url).toBe("https://github.com/org/repo/pull/42"); // CI status block - const ciBlock = body.blocks.find( + const ciBlock = blocks.find( (b: Record) => b.type === "context" && Array.isArray((b as { elements?: unknown[] }).elements) && typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" && (b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"), ); - expect(ciBlock).toBeDefined(); + expectDefined(ciBlock); expect(ciBlock.elements[0].text).toContain(":x:"); expect(ciBlock.elements[0].text).toContain("failing"); // Last block: divider - expect(body.blocks[body.blocks.length - 1].type).toBe("divider"); + expect(blocks[blocks.length - 1].type).toBe("divider"); }); it("passing CI uses check mark emoji", async () => { @@ -105,16 +129,17 @@ describe("notifier-slack integration", () => { vi.stubGlobal("fetch", fetchMock); const notifier = slackPlugin.create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + await notifier.notify(makeEvent({ data: makeV3Data({ ci: { status: "passing" } }) })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( + const ciBlock = getBlocks(body).find( (b: Record) => b.type === "context" && Array.isArray((b as { elements?: unknown[] }).elements) && typeof (b as { elements: Array<{ text?: string }> }).elements[0]?.text === "string" && (b as { elements: Array<{ text: string }> }).elements[0].text.includes("CI:"), ); + expectDefined(ciBlock); expect(ciBlock.elements[0].text).toContain(":white_check_mark:"); }); @@ -126,8 +151,8 @@ describe("notifier-slack integration", () => { await notifier.notify(makeEvent({ data: {} })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - // Should have: header, section, context, divider = 4 blocks - expect(body.blocks).toHaveLength(4); + // Should have: header, message, fields, timestamp context, divider. + expect(getBlocks(body)).toHaveLength(5); }); }); @@ -147,7 +172,7 @@ describe("notifier-slack integration", () => { await notifier.notify(makeEvent({ priority })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.blocks[0].text.text).toContain(expectedEmoji); + expect(getBlocks(body)[0].text.text).toContain(expectedEmoji); }, ); }); @@ -166,8 +191,10 @@ describe("notifier-slack integration", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); - expect(actionsBlock).toBeDefined(); + const actionsBlock = getBlocks(body).find( + (b: Record) => b.type === "actions", + ); + expectDefined(actionsBlock); expect(actionsBlock.elements).toHaveLength(2); // URL button @@ -194,7 +221,10 @@ describe("notifier-slack integration", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getBlocks(body).find( + (b: Record) => b.type === "actions", + ); + expectDefined(actionsBlock); expect(actionsBlock.elements).toHaveLength(1); expect(actionsBlock.elements[0].text.text).toBe("Has Link"); }); @@ -207,7 +237,9 @@ describe("notifier-slack integration", () => { await notifier.notifyWithActions!(makeEvent(), [{ label: "No Link" }]); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getBlocks(body).find( + (b: Record) => b.type === "actions", + ); expect(actionsBlock).toBeUndefined(); }); }); @@ -287,7 +319,12 @@ describe("notifier-slack integration", () => { await notifier.notify(makeEvent({ timestamp: ts })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const contextText = body.blocks[2].elements[0].text; + const contextBlock = getBlocks(body).find( + (block) => + block.type === "context" && + block.elements?.[0]?.text?.includes("Sent by Agent Orchestrator"), + ); + const contextText = contextBlock!.elements[0].text; const unixTs = Math.floor(ts.getTime() / 1000); expect(contextText).toContain(`( return executeWithRetry(); } +async function retryExternal(operation: () => Promise, attempts = 3): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await operation(); + } catch (err) { + lastError = err; + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + } + throw lastError; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -157,13 +172,15 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // ------------------------------------------------------------------------- beforeAll(async () => { - const result = await tracker.createIssue!( - { - title: `[AO Integration Test] ${new Date().toISOString()}`, - description: "Automated integration test issue. Safe to delete if found lingering.", - priority: 4, // Low - }, - project, + const result = await retryExternal(() => + tracker.createIssue!( + { + title: `[AO Integration Test] ${new Date().toISOString()}`, + description: "Automated integration test issue. Safe to delete if found lingering.", + priority: 4, // Low + }, + project, + ), ); issueIdentifier = result.id; @@ -180,7 +197,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { issueUuid = undefined; } } - }, 30_000); + }, 120_000); // ------------------------------------------------------------------------- // Cleanup — archive the test issue so it doesn't clutter the board. @@ -220,7 +237,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { } catch { // Best-effort cleanup } - }, 15_000); + }, 60_000); // ------------------------------------------------------------------------- // Test cases diff --git a/packages/notifier-macos/assets/AppIcon.svg b/packages/notifier-macos/assets/AppIcon.svg new file mode 100644 index 000000000..a3fba9ecc --- /dev/null +++ b/packages/notifier-macos/assets/AppIcon.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/notifier-macos/package.json b/packages/notifier-macos/package.json new file mode 100644 index 000000000..5b5a760b1 --- /dev/null +++ b/packages/notifier-macos/package.json @@ -0,0 +1,40 @@ +{ + "name": "@aoagents/ao-notifier-macos", + "version": "0.6.0", + "description": "Native macOS notification helper app for AO", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "assets", + "src", + "scripts" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/notifier-macos" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "node scripts/build.mjs", + "clean": "rm -rf dist", + "sign": "node scripts/sign.mjs", + "notarize": "node scripts/notarize.mjs" + } +} diff --git a/packages/notifier-macos/scripts/build.mjs b/packages/notifier-macos/scripts/build.mjs new file mode 100644 index 000000000..327e5d685 --- /dev/null +++ b/packages/notifier-macos/scripts/build.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +import { Buffer } from "node:buffer"; +import { execFileSync } from "node:child_process"; +import console from "node:console"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(__dirname, ".."); +const distDir = resolve(packageDir, "dist"); +const appName = "AO Notifier.app"; +const appDir = resolve(distDir, appName); +const contentsDir = resolve(appDir, "Contents"); +const macOsDir = resolve(contentsDir, "MacOS"); +const resourcesDir = resolve(contentsDir, "Resources"); +const executablePath = resolve(macOsDir, "ao-notifier"); +const placeholderMarkerPath = resolve(resourcesDir, "ao-notifier-placeholder"); +const swiftSource = resolve(packageDir, "src", "AONotifier.swift"); +const sourceIconSvg = resolve(packageDir, "assets", "AppIcon.svg"); + +function commandExists(command) { + try { + execFileSync(command, ["--version"], { stdio: "ignore", windowsHide: true }); + return true; + } catch (error) { + return error?.code !== "ENOENT"; + } +} + +function crc32(buffer) { + let crc = ~0; + for (let i = 0; i < buffer.length; i += 1) { + crc ^= buffer[i]; + for (let j = 0; j < 8; j += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return ~crc >>> 0; +} + +function pngChunk(type, data) { + const typeBuffer = Buffer.from(type); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0); + return Buffer.concat([length, typeBuffer, data, crc]); +} + +function makePng(size) { + const raw = Buffer.alloc((size * 4 + 1) * size); + for (let y = 0; y < size; y += 1) { + const rowStart = y * (size * 4 + 1); + raw[rowStart] = 0; + for (let x = 0; x < size; x += 1) { + const offset = rowStart + 1 + x * 4; + const inA = x > size * 0.22 && x < size * 0.42 && y > size * 0.22 && y < size * 0.78; + const inO = + x > size * 0.52 && + x < size * 0.80 && + y > size * 0.22 && + y < size * 0.78 && + !(x > size * 0.60 && x < size * 0.72 && y > size * 0.34 && y < size * 0.66); + const inABar = x > size * 0.30 && x < size * 0.50 && y > size * 0.47 && y < size * 0.57; + const mark = inA || inO || inABar; + raw[offset] = mark ? 255 : 20; + raw[offset + 1] = mark ? 255 : 24; + raw[offset + 2] = mark ? 255 : 32; + raw[offset + 3] = 255; + } + } + + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; + header[9] = 6; + header[10] = 0; + header[11] = 0; + header[12] = 0; + + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", header), + pngChunk("IDAT", zlib.deflateSync(raw)), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +function writeInfoPlist() { + writeFileSync( + resolve(contentsDir, "Info.plist"), + ` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ao-notifier + CFBundleIdentifier + com.aoagents.notifier + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AO Notifier + CFBundleDisplayName + AO Notifier + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.6.0 + CFBundleVersion + 0.6.0 + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSUserNotificationAlertStyle + alert + + +`, + ); +} + +function writeIcon() { + const iconsetDir = resolve(resourcesDir, "AppIcon.iconset"); + rmSync(iconsetDir, { recursive: true, force: true }); + mkdirSync(iconsetDir, { recursive: true }); + const sizes = [16, 32, 64, 128, 256, 512, 1024]; + + const canRenderSvgIcon = existsSync(sourceIconSvg) && commandExists("sips"); + if (canRenderSvgIcon) { + for (const size of sizes) { + execFileSync( + "sips", + [ + "-s", + "format", + "png", + "--resampleHeightWidth", + String(size), + String(size), + sourceIconSvg, + "--out", + resolve(iconsetDir, `icon_${size}x${size}.png`), + ], + { stdio: "ignore" }, + ); + } + } else { + for (const size of sizes) { + writeFileSync(resolve(iconsetDir, `icon_${size}x${size}.png`), makePng(size)); + } + } + + if (process.platform === "darwin" && commandExists("iconutil")) { + try { + execFileSync("iconutil", ["-c", "icns", iconsetDir, "-o", resolve(resourcesDir, "AppIcon.icns")], { + stdio: "ignore", + }); + rmSync(iconsetDir, { recursive: true, force: true }); + } catch { + // The PNG iconset remains usable as a build artifact even if iconutil is unavailable. + } + } +} + +function writeDistIndex() { + writeFileSync( + resolve(distDir, "index.js"), + `import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const appName = "AO Notifier.app"; +export const bundleId = "com.aoagents.notifier"; + +export function getBundledAppPath() { + return resolve(__dirname, appName); +} +`, + ); + writeFileSync( + resolve(distDir, "index.d.ts"), + `export declare const appName = "AO Notifier.app"; +export declare const bundleId = "com.aoagents.notifier"; +export declare function getBundledAppPath(): string; +`, + ); +} + +function writePlaceholderExecutable() { + writeFileSync( + executablePath, + `#!/usr/bin/env sh +echo "AO Notifier.app requires macOS with Swift tooling to build." >&2 +exit 1 +`, + { mode: 0o755 }, + ); + writeFileSync(placeholderMarkerPath, "native macOS build unavailable\n"); +} + +rmSync(distDir, { recursive: true, force: true }); +mkdirSync(macOsDir, { recursive: true }); +mkdirSync(resourcesDir, { recursive: true }); +writeInfoPlist(); +writeIcon(); +writeDistIndex(); + +if (process.platform !== "darwin" || !commandExists("swiftc")) { + writePlaceholderExecutable(); + console.log("Built AO Notifier placeholder app (native macOS build unavailable)."); + process.exit(0); +} + +execFileSync( + "swiftc", + [ + "-O", + "-framework", + "AppKit", + "-framework", + "Foundation", + "-framework", + "UserNotifications", + swiftSource, + "-o", + executablePath, + ], + { stdio: "inherit" }, +); + +if (commandExists("codesign")) { + try { + execFileSync("codesign", ["--force", "--sign", "-", appDir], { stdio: "ignore" }); + } catch { + console.warn("Could not ad-hoc sign AO Notifier.app."); + } +} + +console.log(`Built ${appDir}`); diff --git a/packages/notifier-macos/scripts/notarize.mjs b/packages/notifier-macos/scripts/notarize.mjs new file mode 100644 index 000000000..2c9353765 --- /dev/null +++ b/packages/notifier-macos/scripts/notarize.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import console from "node:console"; +import { existsSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(__dirname, "..", "dist", "AO Notifier.app"); +const zipPath = resolve(__dirname, "..", "dist", "AO Notifier.zip"); + +const appleId = process.env["APPLE_NOTARY_APPLE_ID"]; +const teamId = process.env["APPLE_NOTARY_TEAM_ID"]; +const password = process.env["APPLE_NOTARY_PASSWORD"]; + +if (process.platform !== "darwin") { + console.log("Skipping macOS notarization on non-darwin platform."); + process.exit(0); +} + +if (!appleId || !teamId || !password) { + console.error( + "Set APPLE_NOTARY_APPLE_ID, APPLE_NOTARY_TEAM_ID, and APPLE_NOTARY_PASSWORD to notarize.", + ); + process.exit(1); +} + +if (!existsSync(appDir)) { + console.error(`Missing app bundle: ${appDir}`); + process.exit(1); +} + +rmSync(zipPath, { force: true }); +execFileSync("ditto", ["-c", "-k", "--keepParent", appDir, zipPath], { stdio: "inherit" }); +execFileSync( + "xcrun", + [ + "notarytool", + "submit", + zipPath, + "--apple-id", + appleId, + "--team-id", + teamId, + "--password", + password, + "--wait", + ], + { stdio: "inherit" }, +); +execFileSync("xcrun", ["stapler", "staple", appDir], { stdio: "inherit" }); +console.log("Notarized and stapled AO Notifier.app."); diff --git a/packages/notifier-macos/scripts/sign.mjs b/packages/notifier-macos/scripts/sign.mjs new file mode 100644 index 000000000..50437bf43 --- /dev/null +++ b/packages/notifier-macos/scripts/sign.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import console from "node:console"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(__dirname, "..", "dist", "AO Notifier.app"); +const identity = process.env["APPLE_CODESIGN_IDENTITY"] ?? "-"; + +if (process.platform !== "darwin") { + console.log("Skipping macOS signing on non-darwin platform."); + process.exit(0); +} + +if (!existsSync(appDir)) { + console.error(`Missing app bundle: ${appDir}`); + process.exit(1); +} + +execFileSync("codesign", ["--force", "--deep", "--options", "runtime", "--sign", identity, appDir], { + stdio: "inherit", +}); +console.log(`Signed AO Notifier.app with ${identity === "-" ? "ad-hoc identity" : identity}.`); diff --git a/packages/notifier-macos/src/AONotifier.swift b/packages/notifier-macos/src/AONotifier.swift new file mode 100644 index 000000000..425d01643 --- /dev/null +++ b/packages/notifier-macos/src/AONotifier.swift @@ -0,0 +1,394 @@ +import AppKit +import Foundation +import UserNotifications + +let appName = "AO Notifier" +let appVersion = "0.6.0" +let bundleId = "com.aoagents.notifier" + +struct NotifyPayload: Codable { + struct Event: Codable { + let id: String + let type: String + let priority: String + let sessionId: String + let projectId: String + let timestamp: String + } + + struct Action: Codable { + let label: String + let url: String? + let callbackEndpoint: String? + } + + let title: String + let subtitle: String? + let body: String + let sound: Bool + let notificationId: String? + let threadId: String? + let defaultOpenUrl: String? + let event: Event + let actions: [Action]? +} + +final class NotificationResponseDelegate: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + let actionIdentifier = response.actionIdentifier + + if actionIdentifier == UNNotificationDefaultActionIdentifier { + if let defaultOpenUrl = userInfo["defaultOpenUrl"] as? String { + openUrl(defaultOpenUrl) + completionHandler() + return + } + if let actionUrls = userInfo["actionUrls"] as? [String: String], + let fallbackUrl = actionUrls.sorted(by: { $0.key < $1.key }).first?.value + { + openUrl(fallbackUrl) + } + completionHandler() + return + } + + if let actionCallbacks = userInfo["actionCallbacks"] as? [String: String], + let callbackEndpoint = actionCallbacks[actionIdentifier] + { + postCallback(callbackEndpoint) + completionHandler() + return + } + + if let actionUrls = userInfo["actionUrls"] as? [String: String], + let url = actionUrls[actionIdentifier] + { + openUrl(url) + } + + completionHandler() + } +} + +let delegate = NotificationResponseDelegate() + +func jsonEscape(_ value: String) -> String { + let data = try? JSONSerialization.data(withJSONObject: [value], options: []) + let encoded = String(data: data ?? Data("[]".utf8), encoding: .utf8) ?? "[\"\"]" + return String(encoded.dropFirst().dropLast()) +} + +func printJson(_ pairs: [(String, String)]) { + let body = pairs.map { key, value in + "\"\(key)\":\(jsonEscape(value))" + }.joined(separator: ",") + print("{\(body)}") +} + +func printJsonObject(_ value: Any) { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]), + let json = String(data: data, encoding: .utf8) + else { + print("{}") + return + } + print(json) +} + +func openUrl(_ rawUrl: String?) { + guard let rawUrl = rawUrl, let url = URL(string: rawUrl) else { return } + NSWorkspace.shared.open(url) +} + +func postCallback(_ rawUrl: String?) { + guard let rawUrl = rawUrl, let url = URL(string: rawUrl) else { return } + guard url.scheme == "http" || url.scheme == "https" else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = Data("{\"source\":\"ao-notifier\"}".utf8) + + let semaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: request) { _, _, _ in + semaphore.signal() + } + task.resume() + _ = semaphore.wait(timeout: .now() + 10) +} + +func waitForSettings(_ center: UNUserNotificationCenter) -> UNNotificationSettings? { + let semaphore = DispatchSemaphore(value: 0) + var resolved: UNNotificationSettings? + center.getNotificationSettings { settings in + resolved = settings + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + return resolved +} + +func notificationSettingStatus(_ setting: UNNotificationSetting) -> String { + switch setting { + case .enabled: + return "enabled" + case .disabled: + return "disabled" + case .notSupported: + return "not_supported" + @unknown default: + return "unknown" + } +} + +func permissionStatus() -> String { + guard let settings = waitForSettings(UNUserNotificationCenter.current()) else { + return "unknown" + } + switch settings.authorizationStatus { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .notDetermined: + return "not_determined" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown" + } +} + +func requestPermission() -> Bool { + let center = UNUserNotificationCenter.current() + let semaphore = DispatchSemaphore(value: 0) + var granted = false + center.requestAuthorization(options: [.alert, .sound, .badge]) { allowed, _ in + granted = allowed + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 30) + return granted +} + +func waitForDeliveredNotifications(_ center: UNUserNotificationCenter) -> [UNNotification] { + let semaphore = DispatchSemaphore(value: 0) + var resolved: [UNNotification] = [] + center.getDeliveredNotifications { notifications in + resolved = notifications + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + return resolved +} + +func printDeliveredNotifications() { + let delivered = waitForDeliveredNotifications(UNUserNotificationCenter.current()) + let rows = delivered.map { notification -> [String: Any] in + let content = notification.request.content + return [ + "identifier": notification.request.identifier, + "threadIdentifier": content.threadIdentifier, + "categoryIdentifier": content.categoryIdentifier, + "title": content.title, + "subtitle": content.subtitle, + "body": content.body, + "badge": content.badge?.intValue ?? 0, + "eventId": content.userInfo["eventId"] as? String ?? "", + "eventType": content.userInfo["eventType"] as? String ?? "", + "sessionId": content.userInfo["sessionId"] as? String ?? "", + "projectId": content.userInfo["projectId"] as? String ?? "", + "notificationId": content.userInfo["notificationId"] as? String ?? "", + "threadId": content.userInfo["threadId"] as? String ?? "", + ] + } + printJsonObject([ + "count": rows.count, + "notifications": rows, + ]) +} + +func clearDeliveredNotifications() { + let center = UNUserNotificationCenter.current() + let identifiers = waitForDeliveredNotifications(center).map { $0.request.identifier } + if identifiers.isEmpty { + return + } + + center.removeDeliveredNotifications(withIdentifiers: identifiers) + NSApplication.shared.dockTile.badgeLabel = nil + Thread.sleep(forTimeInterval: 0.5) +} + +func decodePayload(_ base64: String) throws -> NotifyPayload { + guard let data = Data(base64Encoded: base64) else { + throw NSError(domain: appName, code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 payload"]) + } + return try JSONDecoder().decode(NotifyPayload.self, from: data) +} + +func fallbackNotificationId(_ eventId: String) -> String { + return "\(eventId).\(UUID().uuidString)" +} + +func fallbackThreadId() -> String { + return "ao.notifications" +} + +func sendNotification(_ payload: NotifyPayload) throws { + let center = UNUserNotificationCenter.current() + center.delegate = delegate + + var actionUrls: [String: String] = [:] + var actionCallbacks: [String: String] = [:] + let configuredUrlActions = (payload.actions ?? []).enumerated().compactMap { index, action -> UNNotificationAction? in + guard action.url != nil || action.callbackEndpoint != nil else { return nil } + let identifier = "ao.action.\(index)" + if let url = action.url { + actionUrls[identifier] = url + } + if let callbackEndpoint = action.callbackEndpoint { + actionCallbacks[identifier] = callbackEndpoint + } + return UNNotificationAction( + identifier: identifier, + title: action.label, + options: [.foreground] + ) + } + + let categoryId: String? + if configuredUrlActions.isEmpty { + categoryId = nil + } else { + let id = "ao.event.\(payload.event.id)" + let category = UNNotificationCategory( + identifier: id, + actions: configuredUrlActions, + intentIdentifiers: [], + options: [] + ) + center.setNotificationCategories([category]) + categoryId = id + } + + let content = UNMutableNotificationContent() + let threadId = payload.threadId ?? fallbackThreadId() + content.title = payload.title + if let subtitle = payload.subtitle { + content.subtitle = subtitle + } + content.body = payload.body + content.threadIdentifier = threadId + if let categoryId = categoryId { + content.categoryIdentifier = categoryId + } + content.badge = NSNumber(value: waitForDeliveredNotifications(center).count + 1) + if payload.sound { + content.sound = .default + } + + var userInfo: [String: Any] = [ + "eventId": payload.event.id, + "eventType": payload.event.type, + "sessionId": payload.event.sessionId, + "projectId": payload.event.projectId, + "threadId": threadId, + "actionUrls": actionUrls, + "actionCallbacks": actionCallbacks, + ] + let requestId = payload.notificationId ?? fallbackNotificationId(payload.event.id) + userInfo["notificationId"] = requestId + if let defaultOpenUrl = payload.defaultOpenUrl { + userInfo["defaultOpenUrl"] = defaultOpenUrl + } + content.userInfo = userInfo + + let request = UNNotificationRequest(identifier: requestId, content: content, trigger: nil) + let semaphore = DispatchSemaphore(value: 0) + var sendError: Error? + center.add(request) { error in + sendError = error + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + if let sendError = sendError { + throw sendError + } +} + +func runCommand(_ args: [String]) -> Int32 { + let center = UNUserNotificationCenter.current() + center.delegate = delegate + + guard let command = args.first else { + RunLoop.current.run(until: Date().addingTimeInterval(5)) + return 0 + } + + do { + switch command { + case "--version-json": + printJson([ + ("name", appName), + ("version", appVersion), + ("bundleId", bundleId), + ]) + return 0 + case "--permission-status-json": + let settings = waitForSettings(center) + printJson([ + ("status", permissionStatus()), + ("badge", settings.map { notificationSettingStatus($0.badgeSetting) } ?? "unknown"), + ("bundleId", bundleId), + ]) + return 0 + case "--delivered-json": + printDeliveredNotifications() + return 0 + case "--clear-delivered": + clearDeliveredNotifications() + printJson([ + ("cleared", "true"), + ("bundleId", bundleId), + ]) + return 0 + case "--request-permission": + let granted = requestPermission() + let settings = waitForSettings(center) + printJson([ + ("status", granted ? "authorized" : permissionStatus()), + ("badge", settings.map { notificationSettingStatus($0.badgeSetting) } ?? "unknown"), + ("bundleId", bundleId), + ]) + return granted ? 0 : 2 + case "--notify-base64": + guard args.count >= 2 else { + fputs("Missing --notify-base64 payload\n", stderr) + return 64 + } + let status = permissionStatus() + if status == "not_determined" { + _ = requestPermission() + } + try sendNotification(decodePayload(args[1])) + return 0 + default: + fputs("Unknown command: \(command)\n", stderr) + return 64 + } + } catch { + fputs("\(error.localizedDescription)\n", stderr) + return 1 + } +} + +exit(runCommand(Array(CommandLine.arguments.dropFirst()))) diff --git a/packages/plugins/notifier-composio/package.json b/packages/plugins/notifier-composio/package.json index ef78c917f..81a08f554 100644 --- a/packages/plugins/notifier-composio/package.json +++ b/packages/plugins/notifier-composio/package.json @@ -34,15 +34,9 @@ "clean": "rm -rf dist" }, "dependencies": { - "@aoagents/ao-core": "workspace:*" - }, - "peerDependencies": { - "composio-core": ">=0.5.0" - }, - "peerDependenciesMeta": { - "composio-core": { - "optional": true - } + "@aoagents/ao-core": "workspace:*", + "@composio/core": "^0.9.0", + "zod": "^3.25.76" }, "devDependencies": { "@types/node": "^25.2.3", diff --git a/packages/plugins/notifier-composio/src/index.test.ts b/packages/plugins/notifier-composio/src/index.test.ts index 99dbcb2ae..ab0168aa7 100644 --- a/packages/plugins/notifier-composio/src/index.test.ts +++ b/packages/plugins/notifier-composio/src/index.test.ts @@ -2,6 +2,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { OrchestratorEvent, NotifyAction } from "@aoagents/ao-core"; import { manifest, create } from "./index.js"; +const { mockToolsExecute, mockConstructorOptions } = vi.hoisted(() => ({ + mockToolsExecute: vi.fn().mockResolvedValue({ successful: true }), + mockConstructorOptions: [] as Array>, +})); + +vi.mock("@composio/core", () => { + function MockComposio(opts: Record) { + mockConstructorOptions.push(opts); + return { tools: { execute: mockToolsExecute } }; + } + return { Composio: MockComposio }; +}); + function makeEvent(overrides: Partial = {}): OrchestratorEvent { return { id: "evt-1", @@ -16,29 +29,44 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } -const mockExecuteAction = vi.fn().mockResolvedValue({ successful: true }); +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} -vi.mock("composio-core", () => { - // Must use a regular function (not arrow) to be callable with `new` - function MockComposio() { - return { executeAction: mockExecuteAction }; - } - return { Composio: MockComposio }; -}); +function getSlackAttachment(): any { + const callArgs = mockToolsExecute.mock.calls[0][1]; + return JSON.parse(String(callArgs.arguments.attachments))[0]; +} + +function getSlackActions(): any[] { + const attachment = getSlackAttachment(); + return attachment.blocks.find((block: any) => block.type === "actions")?.elements ?? []; +} describe("notifier-composio", () => { - const originalEnv = process.env.COMPOSIO_API_KEY; + const originalEnv = { + COMPOSIO_API_KEY: process.env.COMPOSIO_API_KEY, + COMPOSIO_USER_ID: process.env.COMPOSIO_USER_ID, + COMPOSIO_ENTITY_ID: process.env.COMPOSIO_ENTITY_ID, + }; beforeEach(() => { vi.clearAllMocks(); + mockConstructorOptions.length = 0; + mockToolsExecute.mockResolvedValue({ successful: true }); delete process.env.COMPOSIO_API_KEY; + delete process.env.COMPOSIO_USER_ID; + delete process.env.COMPOSIO_ENTITY_ID; }); afterEach(() => { - if (originalEnv !== undefined) { - process.env.COMPOSIO_API_KEY = originalEnv; - } else { - delete process.env.COMPOSIO_API_KEY; + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) process.env[key] = value; + else Reflect.deleteProperty(process.env, key); } }); @@ -54,15 +82,24 @@ describe("notifier-composio", () => { }); describe("create — config parsing", () => { - it("reads apiKey from config", () => { + it("reads apiKey from config", async () => { const notifier = create({ composioApiKey: "test-key" }); - expect(notifier.name).toBe("composio"); + await notifier.notify(makeEvent()); + expect(mockConstructorOptions[0]).toEqual({ apiKey: "test-key" }); }); - it("reads apiKey from COMPOSIO_API_KEY env var", () => { + it("reads apiKey from COMPOSIO_API_KEY env var", async () => { process.env.COMPOSIO_API_KEY = "env-key"; const notifier = create(); - expect(notifier.name).toBe("composio"); + await notifier.notify(makeEvent()); + expect(mockConstructorOptions[0]).toEqual({ apiKey: "env-key" }); + }); + + it("resolves env placeholders in composioApiKey config", async () => { + process.env.COMPOSIO_API_KEY = "placeholder-key"; + const notifier = create({ composioApiKey: "${COMPOSIO_API_KEY}" }); + await notifier.notify(makeEvent()); + expect(mockConstructorOptions[0]).toEqual({ apiKey: "placeholder-key" }); }); it("throws on invalid defaultApp", () => { @@ -79,6 +116,12 @@ describe("notifier-composio", () => { expect(() => create({ composioApiKey: "k", defaultApp: "discord" })).not.toThrow(); }); + it("throws on invalid Discord mode", () => { + expect(() => create({ composioApiKey: "k", defaultApp: "discord", mode: "voice" })).toThrow( + 'Invalid Discord mode: "voice"', + ); + }); + it("accepts gmail as defaultApp with emailTo", () => { expect(() => create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" }), @@ -95,9 +138,11 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k" }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", expect.objectContaining({ - action: "SLACK_SEND_MESSAGE", + userId: "aoagent", + arguments: expect.objectContaining({ markdown_text: expect.any(String) }), }), ); }); @@ -108,77 +153,383 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k", defaultApp: "slack" }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith("SLACK_SEND_MESSAGE", expect.any(Object)); + }); + + it("calls DISCORDBOT_CREATE_MESSAGE for discord bot mode", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({ - action: "SLACK_SEND_MESSAGE", + arguments: expect.objectContaining({ + channel_id: "1234567890", + content: expect.stringContaining("Session Spawned"), + embeds: [ + expect.objectContaining({ + title: expect.stringContaining("Session Spawned"), + fields: expect.arrayContaining([ + expect.objectContaining({ name: "Project", value: "my-project" }), + expect.objectContaining({ name: "Session", value: "app-1" }), + ]), + }), + ], + allowed_mentions: { parse: [] }, + }), }), ); }); - it("calls DISCORD_SEND_MESSAGE for discord app", async () => { - const notifier = create({ composioApiKey: "k", defaultApp: "discord" }); + it("calls DISCORDBOT_EXECUTE_WEBHOOK for discord webhook mode", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + connectedAccountId: "ca_discord_webhook", + }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_EXECUTE_WEBHOOK", expect.objectContaining({ - action: "DISCORD_SEND_MESSAGE", + arguments: expect.objectContaining({ + webhook_id: "1234567890", + webhook_token: "webhook-token", + content: expect.stringContaining("Session Spawned"), + embeds: [ + expect.objectContaining({ + title: expect.stringContaining("Session Spawned"), + }), + ], + allowed_mentions: { parse: [] }, + }), + connectedAccountId: "ca_discord_webhook", }), ); }); + it("uses webhook mode when Discord webhookUrl is configured without mode", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + webhookUrl: "https://discord.com/api/webhooks/1234567890/webhook-token", + connectedAccountId: "ca_discord_webhook", + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_EXECUTE_WEBHOOK", + expect.any(Object), + ); + }); + + it("fails fast on invalid Discord webhook URLs", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "webhook", + webhookUrl: "https://discord.com/not-a-webhook", + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("Invalid Discord webhookUrl"); + }); + it("calls GMAIL_SEND_EMAIL for gmail app", async () => { const notifier = create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "test@test.com", + connectedAccountId: "ca_gmail", }); await notifier.notify(makeEvent()); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", expect.objectContaining({ - action: "GMAIL_SEND_EMAIL", + connectedAccountId: "ca_gmail", + arguments: expect.objectContaining({ + recipient_email: "test@test.com", + subject: "[AO] Session Spawned: app-1", + is_html: true, + }), }), ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.body).toContain(""); + expect(callArgs.arguments.body).toContain("Session Spawned"); + expect(callArgs.arguments.body).toContain("Session app-1 spawned successfully"); + }); + + it("formats Gmail CI notifications as a professional HTML email brief", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + await notifier.notify( + makeEvent({ + type: "ci.failing", + priority: "action", + sessionId: "demo-agent-19", + projectId: "demo", + message: "CI is failing on PR #1579", + data: makeV3Data({ + subject: { + session: { id: "demo-agent-19", projectId: "demo" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + branch: "ao/demo-notifier-harness", + baseBranch: "main", + }, + issue: { + id: "AO-1579", + title: "Make AO notification payloads API-grade", + }, + }, + ci: { + status: "failing", + failedChecks: [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks", + }, + ], + }, + }), + }), + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.subject).toBe("[AO] CI failing on PR #1579"); + expect(callArgs.arguments.is_html).toBe(true); + expect(callArgs.arguments.body).toContain(""); + expect(callArgs.arguments.body).toContain("CI is failing on PR #1579"); + expect(callArgs.arguments.body).toContain("Normalize AO notifier payloads"); + expect(callArgs.arguments.body).toContain("Action required"); + expect(callArgs.arguments.body).toContain("Pull Request"); + expect(callArgs.arguments.body).toContain("#1579 - Normalize AO notifier payloads"); + expect(callArgs.arguments.body).toContain("typecheck: failed/FAILURE"); + expect(callArgs.arguments.body).not.toContain("👉"); }); it("routes to channelId when set", async () => { const notifier = create({ composioApiKey: "k", channelId: "C123" }); await notifier.notify(makeEvent()); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.channel).toBe("C123"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.channel).toBe("C123"); }); - it("routes to channelName when channelId not set", async () => { + it("routes to normalized channelName when channelId not set", async () => { const notifier = create({ composioApiKey: "k", channelName: "#general" }); await notifier.notify(makeEvent()); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.channel).toBe("#general"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.channel).toBe("general"); }); - it("includes priority emoji in text", async () => { + it("formats Slack notifications as rich attachments", async () => { const notifier = create({ composioApiKey: "k" }); await notifier.notify(makeEvent({ priority: "urgent" })); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("\u{1F6A8}"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.markdown_text).toContain("Urgent"); + expect(callArgs.arguments.text).toContain("Urgent"); + expect(callArgs.arguments.unfurl_links).toBe(false); + expect(callArgs.arguments.unfurl_media).toBe(false); + + const attachment = getSlackAttachment(); + expect(attachment.color).toBe("#E01E5A"); + expect(attachment.fallback).toContain("Urgent"); + expect(attachment.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "header", + text: expect.objectContaining({ + text: expect.stringContaining(":rotating_light:"), + }), + }), + expect.objectContaining({ + type: "section", + fields: expect.arrayContaining([ + expect.objectContaining({ text: expect.stringContaining("*Project*") }), + expect.objectContaining({ text: expect.stringContaining("my-project") }), + ]), + }), + ]), + ); }); - it("includes prUrl when present as string", async () => { + it("escapes user-controlled Slack mrkdwn characters", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify( + makeEvent({ message: "Fix *bold* _italic_ ~strike~ `code` & > done" }), + ); + + const section = getSlackAttachment().blocks.find((block: any) => block.type === "section"); + expect(section.text.text).toBe( + "Fix *bold* _italic_ ~strike~ `code` & <tag> > done", + ); + }); + + it("escapes right parentheses in Discord markdown link URLs", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { + number: 1, + title: "Parser test", + url: "https://github.com/org/repo/pull/1?a=(test)", + }, + }, + }), + }), + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + const pullRequestField = callArgs.arguments.embeds[0].fields.find( + (field: any) => field.name === "Pull Request", + ); + expect(pullRequestField.value).toContain( + "[#1](https://github.com/org/repo/pull/1?a=(test%29)", + ); + }); + + it("includes subject.pr.url when present in v3 data", async () => { + const notifier = create({ composioApiKey: "k" }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { number: 1, url: "https://github.com/pull/1" }, + }, + }), + }), + ); + + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "View PR" }), + url: "https://github.com/pull/1", + style: "primary", + }), + ]), + ); + }); + + it("ignores legacy flat prUrl", async () => { const notifier = create({ composioApiKey: "k" }); await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/pull/1" } })); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("https://github.com/pull/1"); + expect(getSlackActions()).toEqual([]); + expect(getSlackAttachment().fallback).not.toContain("https://github.com/pull/1"); }); - it("ignores prUrl when not a string", async () => { - const notifier = create({ composioApiKey: "k" }); - await notifier.notify(makeEvent({ data: { prUrl: 42 } })); + it("passes userId, connectedAccountId, and default Slack tool version", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "slack", + userId: "user_123", + connectedAccountId: "ca_123", + }); + await notifier.notify(makeEvent()); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).not.toContain("PR:"); + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ + userId: "user_123", + connectedAccountId: "ca_123", + version: "20260508_00", + }), + ); + }); + + it("keeps entityId as a backward-compatible userId alias", async () => { + const notifier = create({ composioApiKey: "k", entityId: "legacy-user" }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ userId: "legacy-user" }), + ); + }); + + it("reads userId from COMPOSIO_USER_ID env var", async () => { + process.env.COMPOSIO_USER_ID = "env-user"; + const notifier = create({ composioApiKey: "k" }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ userId: "env-user" }), + ); + }); + + it("supports configured toolVersion overrides", async () => { + const notifier = create({ composioApiKey: "k", toolVersion: "20260101_00" }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ version: "20260101_00" }), + ); + }); + + it("supports app-specific toolVersions overrides", async () => { + const notifier = create({ + composioApiKey: "k", + toolVersion: "ignored", + toolVersions: { slack: "20260202_00" }, + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ version: "20260202_00" }), + ); + }); + + it("passes the default Gmail tool version", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + await notifier.notify(makeEvent()); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", + expect.objectContaining({ version: "20260506_01" }), + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("No toolVersion configured"), + ); + warnSpy.mockRestore(); }); }); @@ -191,9 +542,21 @@ describe("notifier-composio", () => { ]; await notifier.notifyWithActions!(makeEvent(), actions); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("Merge"); - expect(callArgs.params.text).toContain("Kill"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "Merge" }), + url: "https://github.com/merge", + style: "primary", + }), + expect.objectContaining({ + text: expect.objectContaining({ text: "Kill" }), + action_id: "ao_kill_1", + value: "/api/kill", + style: "danger", + }), + ]), + ); }); it("includes URL actions as links", async () => { @@ -201,8 +564,14 @@ describe("notifier-composio", () => { const actions: NotifyAction[] = [{ label: "View PR", url: "https://github.com/pull/42" }]; await notifier.notifyWithActions!(makeEvent(), actions); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("https://github.com/pull/42"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "View PR" }), + url: "https://github.com/pull/42", + }), + ]), + ); }); it("renders callback-only actions without URL", async () => { @@ -210,20 +579,106 @@ describe("notifier-composio", () => { const actions: NotifyAction[] = [{ label: "Restart", callbackEndpoint: "/api/restart" }]; await notifier.notifyWithActions!(makeEvent(), actions); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toContain("- Restart"); + expect(getSlackActions()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.objectContaining({ text: "Restart" }), + action_id: "ao_restart_0", + value: "/api/restart", + }), + ]), + ); }); it("uses correct tool slug for configured app", async () => { - const notifier = create({ composioApiKey: "k", defaultApp: "discord" }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create({ + composioApiKey: "k", + defaultApp: "discord", + mode: "bot", + channelId: "1234567890", + }); const actions: NotifyAction[] = [{ label: "Test", url: "https://example.com" }]; await notifier.notifyWithActions!(makeEvent(), actions); - expect(mockExecuteAction).toHaveBeenCalledWith( + expect(mockToolsExecute).toHaveBeenCalledWith( + "DISCORDBOT_CREATE_MESSAGE", expect.objectContaining({ - action: "DISCORD_SEND_MESSAGE", + arguments: expect.objectContaining({ + embeds: [ + expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "Actions", + value: expect.stringContaining("[Test](https://example.com)"), + }), + ]), + }), + ], + components: [ + { + type: 1, + components: [{ type: 2, style: 5, label: "Test", url: "https://example.com" }], + }, + ], + }), }), ); + warnSpy.mockRestore(); + }); + + it("formats Gmail actions with a professional subject and action links", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + const actions: NotifyAction[] = [ + { label: "Open dashboard", url: "http://localhost:3000" }, + { label: "Acknowledge", callbackEndpoint: "http://localhost:3000/api/ack" }, + ]; + await notifier.notifyWithActions!( + makeEvent({ + type: "merge.ready", + priority: "action", + sessionId: "demo-agent-29", + projectId: "demo", + message: "PR #1579 is ready to merge", + data: makeV3Data({ + subject: { + session: { id: "demo-agent-29", projectId: "demo" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + }, + }, + transition: { kind: "session_status", from: "approved", to: "mergeable" }, + ci: { status: "passing" }, + review: { decision: "approved" }, + merge: { ready: true, conflicts: false, isBehind: false }, + }), + }), + actions, + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.subject).toBe("[AO] PR #1579 ready to merge"); + expect(callArgs.arguments.is_html).toBe(true); + expect(callArgs.arguments.body).toContain(""); + expect(callArgs.arguments.body).toContain("Ready to merge"); + expect(callArgs.arguments.body).toContain("PR #1579 is ready to merge"); + expect(callArgs.arguments.body).toContain("Normalize AO notifier payloads"); + expect(callArgs.arguments.body).toContain("View pull request"); + expect(callArgs.arguments.body).toContain("Transition"); + expect(callArgs.arguments.body).toContain("approved -> mergeable"); + expect(callArgs.arguments.body).toContain("Passing"); + expect(callArgs.arguments.body).toContain("Approved"); + expect(callArgs.arguments.body).toContain("Ready"); + expect(callArgs.arguments.body).toContain("Actions"); + expect(callArgs.arguments.body).toContain('href="http://localhost:3000"'); + expect(callArgs.arguments.body).toContain('href="http://localhost:3000/api/ack"'); }); }); @@ -232,16 +687,42 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k" }); await notifier.post!("Hello from AO"); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.text).toBe("Hello from AO"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.markdown_text).toBe("Hello from AO"); }); it("overrides channel from context", async () => { const notifier = create({ composioApiKey: "k", channelName: "#default" }); await notifier.post!("test", { channel: "#override" }); - const callArgs = mockExecuteAction.mock.calls[0][0]; - expect(callArgs.params.channel).toBe("#override"); + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.channel).toBe("override"); + }); + + it("uses Gmail recipient_email for HTML post messages", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + await notifier.post!("Hello from AO"); + + expect(mockToolsExecute).toHaveBeenCalledWith( + "GMAIL_SEND_EMAIL", + expect.objectContaining({ + connectedAccountId: "ca_gmail", + arguments: { + recipient_email: "test@test.com", + subject: "Agent Orchestrator Message", + body: expect.stringContaining(""), + is_html: true, + }, + }), + ); + + const callArgs = mockToolsExecute.mock.calls[0][1]; + expect(callArgs.arguments.body).toContain("Hello from AO"); }); it("returns null", async () => { @@ -253,7 +734,7 @@ describe("notifier-composio", () => { describe("error handling", () => { it("throws when SDK returns unsuccessful result", async () => { - mockExecuteAction.mockResolvedValueOnce({ + mockToolsExecute.mockResolvedValueOnce({ successful: false, error: "channel not found", }); @@ -263,7 +744,7 @@ describe("notifier-composio", () => { }); it("wraps SDK error with descriptive message", async () => { - mockExecuteAction.mockResolvedValueOnce({ + mockToolsExecute.mockResolvedValueOnce({ successful: false, error: undefined, }); @@ -271,6 +752,46 @@ describe("notifier-composio", () => { const notifier = create({ composioApiKey: "k" }); await expect(notifier.notify(makeEvent())).rejects.toThrow("unknown error"); }); + + it("adds setup guidance when no connected account is found", async () => { + mockToolsExecute.mockRejectedValueOnce( + new Error("No connected account found for user default for toolkit slack"), + ); + + const notifier = create({ composioApiKey: "k" }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("ao setup composio"); + }); + + it("uses mail setup guidance for Gmail connection errors", async () => { + mockToolsExecute.mockRejectedValueOnce( + new Error("No connected account found for user aoagent for toolkit gmail"), + ); + + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + connectedAccountId: "ca_gmail", + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("ao setup composio-mail"); + }); + + it("requires connectedAccountId before executing Gmail notifications", async () => { + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("connectedAccountId is required"); + expect(mockToolsExecute).not.toHaveBeenCalled(); + }); + + it("rejects invalid test client overrides", () => { + expect(() => create({ composioApiKey: "k", _clientOverride: {} })).toThrow("tools.execute"); + }); }); describe("no-op when no apiKey", () => { @@ -278,9 +799,33 @@ describe("notifier-composio", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const notifier = create(); await notifier.notify(makeEvent()); - expect(mockExecuteAction).not.toHaveBeenCalled(); + expect(mockToolsExecute).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No composioApiKey")); warnSpy.mockRestore(); }); }); + + describe("client override", () => { + it("supports direct @composio/core tools.execute clients", async () => { + const execute = vi.fn().mockResolvedValue({ successful: true }); + const notifier = create({ + composioApiKey: "k", + userId: "user_123", + connectedAccountId: "ca_123", + _clientOverride: { tools: { execute } }, + }); + + await notifier.notify(makeEvent()); + + expect(execute).toHaveBeenCalledWith( + "SLACK_SEND_MESSAGE", + expect.objectContaining({ + userId: "user_123", + connectedAccountId: "ca_123", + arguments: expect.objectContaining({ markdown_text: expect.any(String) }), + }), + ); + expect(mockToolsExecute).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/plugins/notifier-composio/src/index.ts b/packages/plugins/notifier-composio/src/index.ts index d3dd456ab..6e9372ffb 100644 --- a/packages/plugins/notifier-composio/src/index.ts +++ b/packages/plugins/notifier-composio/src/index.ts @@ -1,9 +1,12 @@ import { + getNotificationDataV3, recordActivityEvent, type EventPriority, type Notifier, type NotifyAction, type NotifyContext, + type NotificationCICheck, + type NotificationDataV3, type OrchestratorEvent, type PluginModule, } from "@aoagents/ao-core"; @@ -30,47 +33,166 @@ const PRIORITY_EMOJI: Record = { info: "\u{2139}\u{FE0F}", }; +function getSubjectPRUrl(event: OrchestratorEvent): string | undefined { + return getNotificationDataV3(event.data)?.subject.pr?.url; +} + +function getCIStatus(event: OrchestratorEvent): string | undefined { + return getNotificationDataV3(event.data)?.ci?.status; +} + +function getFailedCheckNames(event: OrchestratorEvent): string[] { + return getNotificationDataV3(event.data)?.ci?.failedChecks?.map((check) => check.name) ?? []; +} + type ComposioApp = "slack" | "discord" | "gmail"; +type DiscordMode = "webhook" | "bot"; const APP_TOOL_SLUG: Record = { slack: "SLACK_SEND_MESSAGE", - discord: "DISCORD_SEND_MESSAGE", + discord: "DISCORDBOT_CREATE_MESSAGE", gmail: "GMAIL_SEND_EMAIL", }; +const DEFAULT_TOOL_VERSION: Partial> = { + slack: "20260508_00", + discord: "20260429_01", + gmail: "20260506_01", +}; + const VALID_APPS = new Set(["slack", "discord", "gmail"]); +const VALID_DISCORD_MODES = new Set(["webhook", "bot"]); +const DEFAULT_COMPOSIO_USER_ID = "aoagent"; const GMAIL_SUBJECT = "Agent Orchestrator Notification"; +const GMAIL_POST_SUBJECT = "Agent Orchestrator Message"; +const DISCORD_WEBHOOK_TOOL_SLUG = "DISCORDBOT_EXECUTE_WEBHOOK"; +const DISCORD_EMBED_TITLE_MAX = 256; +const DISCORD_EMBED_DESCRIPTION_MAX = 4096; +const DISCORD_FIELD_NAME_MAX = 256; +const DISCORD_FIELD_VALUE_MAX = 1024; +const DISCORD_MAX_FIELDS = 25; -interface ComposioToolkit { - executeAction(params: { - action: string; - params: Record; - entityId?: string; - }): Promise<{ successful: boolean; data?: unknown; error?: string }>; +interface ComposioExecuteParams { + userId: string; + connectedAccountId?: string; + version?: string; + dangerouslySkipVersionCheck?: boolean; + arguments: Record; +} + +interface ComposioExecuteResult { + successful?: boolean; + data?: unknown; + error?: unknown; +} + +interface ComposioToolsClient { + tools: { + execute(action: string, params: ComposioExecuteParams): Promise; + }; +} + +interface DiscordTone { + emoji: string; + label: string; + color: number; +} + +interface DiscordEmbed { + title: string; + description: string; + color: number; + url?: string; + fields?: { name: string; value: string; inline?: boolean }[]; + timestamp?: string; + footer?: { text: string }; +} + +interface DiscordComponentButton { + type: 2; + style: 5; + label: string; + url: string; +} + +interface DiscordActionRow { + type: 1; + components: DiscordComponentButton[]; +} + +interface DiscordMessagePayload { + content?: string; + embeds?: DiscordEmbed[]; + components?: DiscordActionRow[]; + allowed_mentions?: { parse: string[] }; +} + +interface SlackTone { + emoji: string; + label: string; + color: string; +} + +interface SlackButton { + type: "button"; + text: { + type: "plain_text"; + text: string; + emoji: true; + }; + url?: string; + action_id?: string; + value?: string; + style?: "primary" | "danger"; +} + +interface SlackAttachment { + color: string; + fallback: string; + blocks: unknown[]; +} + +interface SlackMessagePayload { + markdown_text: string; + text: string; + attachments: string; + unfurl_links: boolean; + unfurl_media: boolean; +} + +function isComposioToolsClient(value: unknown): value is ComposioToolsClient { + return ( + value !== null && + typeof value === "object" && + "tools" in value && + typeof (value as { tools?: { execute?: unknown } }).tools?.execute === "function" + ); } /** - * Lazy-load composio-core SDK. - * Returns null if the package is not installed. + * Lazy-load the bundled @composio/core SDK. * - * We use dynamic import + unknown casting because composio-core is an - * optional peer dependency — it may or may not be installed, and its - * TypeScript types may not match our internal interface exactly. + * Dynamic import keeps the plugin lightweight at module-load time and lets + * tests inject a mock client at the I/O boundary. */ -async function loadComposioSDK(apiKey: string): Promise { +async function loadComposioSDK(apiKey: string): Promise { try { - // String literal import so vitest can intercept it for mocking. - // The `as unknown as …` cast is safe because we validate the shape below. - const mod = (await import("composio-core")) as unknown as Record; + const mod = (await import("@composio/core")) as unknown as Record; const ComposioClass = (mod.Composio ?? (mod.default as Record | undefined)?.Composio ?? mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined; + if (typeof ComposioClass !== "function") { - throw new Error("Could not find Composio class in composio-core module"); + throw new Error("Could not find Composio class in @composio/core module"); } + const client = new ComposioClass({ apiKey }); - return client as ComposioToolkit; + if (!isComposioToolsClient(client)) { + throw new Error("Composio SDK client does not expose tools.execute()"); + } + + return client; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; @@ -88,11 +210,11 @@ async function loadComposioSDK(apiKey: string): Promise source: "notifier", kind: "notifier.dep_missing", level: "error", - summary: "Composio SDK (composio-core) is not installed", + summary: "Composio SDK (@composio/core) is not installed", data: { plugin: "notifier-composio", - package: "composio-core", - installHint: "pnpm add composio-core", + package: "@composio/core", + installHint: "pnpm add @composio/core", }, }); } @@ -102,15 +224,86 @@ async function loadComposioSDK(apiKey: string): Promise } } +function stringConfig( + config: Record | undefined, + key: string, +): string | undefined { + const value = config?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function resolveEnvReference(value: string | undefined): string | undefined { + if (!value) return undefined; + const match = value.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/); + if (!match) return value; + return process.env[match[1] ?? match[2] ?? ""]; +} + +function boolConfig(config: Record | undefined, key: string): boolean { + return config?.[key] === true; +} + +function parseDiscordWebhookUrl(webhookUrl: string): { webhookId: string; webhookToken: string } { + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new Error("[notifier-composio] Invalid Discord webhookUrl."); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + const webhookIndex = segments.findIndex((segment) => segment === "webhooks"); + const webhookId = webhookIndex >= 0 ? segments[webhookIndex + 1] : undefined; + const webhookToken = webhookIndex >= 0 ? segments[webhookIndex + 2] : undefined; + + if (!webhookId || !webhookToken) { + throw new Error( + "[notifier-composio] Invalid Discord webhookUrl. Expected https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN", + ); + } + + return { + webhookId: decodeURIComponent(webhookId), + webhookToken: decodeURIComponent(webhookToken), + }; +} + +function resolveDiscordMode( + config: Record | undefined, + defaultApp: ComposioApp, + webhookUrl: string | undefined, +): DiscordMode | undefined { + if (defaultApp !== "discord") return undefined; + + const mode = stringConfig(config, "mode"); + if (mode) { + if (!VALID_DISCORD_MODES.has(mode)) { + throw new Error( + `[notifier-composio] Invalid Discord mode: "${mode}". Must be one of: webhook, bot`, + ); + } + return mode as DiscordMode; + } + + return webhookUrl ? "webhook" : "bot"; +} + function formatNotifyText(event: OrchestratorEvent): string { const emoji = PRIORITY_EMOJI[event.priority]; const parts = [`${emoji} *${event.type}* — ${event.sessionId}`, event.message]; - const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; + const prUrl = getSubjectPRUrl(event); if (prUrl) { parts.push(`PR: ${prUrl}`); } + const ciStatus = getCIStatus(event); + if (ciStatus) { + const failedChecks = getFailedCheckNames(event); + const failedCheckText = failedChecks.length > 0 ? ` (failed: ${failedChecks.join(", ")})` : ""; + parts.push(`CI: ${ciStatus}${failedCheckText}`); + } + return parts.join("\n"); } @@ -124,56 +317,1252 @@ function formatActionsText(event: OrchestratorEvent, actions: NotifyAction[]): s return `${base}\n\nActions:\n${actionLines.join("\n")}`; } +const DISCORD_SUCCESS_TONE: DiscordTone = { + emoji: "\u{2705}", + label: "Complete", + color: 0x57f287, +}; + +const DISCORD_PRIORITY_TONE: Record = { + urgent: { + emoji: "\u{1F6A8}", + label: "Urgent", + color: 0xed4245, + }, + action: { + emoji: "\u{1F449}", + label: "Action required", + color: 0x5865f2, + }, + warning: { + emoji: "\u{26A0}\u{FE0F}", + label: "Warning", + color: 0xfee75c, + }, + info: { + emoji: "\u{2139}\u{FE0F}", + label: "Information", + color: 0x3498db, + }, +}; + +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function priorityLabel(priority: EventPriority): string { + switch (priority) { + case "urgent": + return "Urgent"; + case "action": + return "Action required"; + case "warning": + return "Warning"; + case "info": + return "Information"; + } +} + +function truncate(value: string, maxLength = 90): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; +} + +function truncateUnicode(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value; +} + +function discordToneForEvent(event: OrchestratorEvent): DiscordTone { + if (event.type === "merge.ready") return { ...DISCORD_SUCCESS_TONE, label: "Ready to merge" }; + if (event.type === "summary.all_complete") { + return { ...DISCORD_SUCCESS_TONE, label: "All complete" }; + } + if (event.type === "ci.failing" || event.type === "session.stuck") { + return DISCORD_PRIORITY_TONE.urgent; + } + if (event.type === "review.changes_requested") return DISCORD_PRIORITY_TONE.warning; + return DISCORD_PRIORITY_TONE[event.priority] ?? DISCORD_PRIORITY_TONE.info; +} + +function formatDiscordTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function formatDiscordValue(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null || value === "") return "Not available"; + return truncateUnicode(String(value), DISCORD_FIELD_VALUE_MAX); +} + +function appendDiscordField( + fields: NonNullable, + name: string, + value: string | number | boolean | undefined | null, + inline = true, +): void { + if (value === undefined || value === null || value === "") return; + if (fields.length >= DISCORD_MAX_FIELDS) return; + fields.push({ + name: truncateUnicode(name, DISCORD_FIELD_NAME_MAX), + value: formatDiscordValue(value), + inline, + }); +} + +function formatDiscordMarkdownLink(label: string, url: string): string { + const safeLabel = label.replaceAll("[", "").replaceAll("]", "").replace(/[()]/g, ""); + const safeUrl = url.replace(/\)/g, "%29"); + return `[${safeLabel}](${safeUrl})`; +} + +function formatDiscordBranch(data: NotificationDataV3 | null): string | undefined { + const pr = data?.subject.pr; + if (pr?.branch && pr.baseBranch) return `${pr.branch} -> ${pr.baseBranch}`; + return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch; +} + +function formatDiscordCheck(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const label = `${check.name}: ${status}`; + return check.url ? formatDiscordMarkdownLink(label, check.url) : label; +} + +function isAbsoluteHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function formatDiscordCiStatus(data: NotificationDataV3): string { + if (!data.ci?.status) return ""; + const ciEmoji = data.ci.status === "passing" ? "\u{2705}" : "\u{274C}"; + const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? []; + const failedCheckText = failedChecks.length > 0 ? `\nFailed: ${failedChecks.join(", ")}` : ""; + return `${ciEmoji} ${titleCaseStatus(data.ci.status)}${failedCheckText}`; +} + +function appendDiscordDataFields( + fields: NonNullable, + data: NotificationDataV3 | null, +): void { + if (!data) return; + + const pr = data.subject.pr; + const issue = data.subject.issue; + const branch = formatDiscordBranch(data); + + appendDiscordField( + fields, + "Pull Request", + pr + ? `${formatDiscordMarkdownLink(`#${pr.number}`, pr.url)}${pr.title ? ` - ${pr.title}` : ""}` + : undefined, + false, + ); + appendDiscordField(fields, "Branch", branch); + appendDiscordField( + fields, + "Issue", + issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined, + ); + appendDiscordField(fields, "CI", data.ci?.status ? formatDiscordCiStatus(data) : undefined); + appendDiscordField( + fields, + "Review", + data.review?.decision ? titleCaseStatus(data.review.decision) : undefined, + ); + appendDiscordField(fields, "Review Threads", data.review?.unresolvedThreads); + appendDiscordField( + fields, + "Merge", + typeof data.merge?.ready === "boolean" ? (data.merge.ready ? "Ready" : "Not ready") : undefined, + ); + appendDiscordField( + fields, + "Conflicts", + typeof data.merge?.conflicts === "boolean" + ? data.merge.conflicts + ? "Found" + : "None" + : undefined, + ); + appendDiscordField( + fields, + "Sync", + typeof data.merge?.isBehind === "boolean" + ? data.merge.isBehind + ? "Behind base" + : "Up to date" + : undefined, + ); + appendDiscordField( + fields, + "Transition", + data.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined, + ); + appendDiscordField( + fields, + "Reaction", + data.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined, + ); + appendDiscordField( + fields, + "Escalation", + data.escalation ? `${data.escalation.attempts} attempts (${data.escalation.cause})` : undefined, + ); + + const checks = (data.ci?.failedChecks ?? []).slice(0, 8).map(formatDiscordCheck); + appendDiscordField(fields, "Checks", checks.length > 0 ? checks.join("\n") : undefined, false); + appendDiscordField( + fields, + "Blockers", + data.merge?.blockers?.length ? data.merge.blockers.slice(0, 8).join("\n") : undefined, + false, + ); + + const links = [ + ...(pr?.url ? [formatDiscordMarkdownLink("Pull request", pr.url)] : []), + ...(data.review?.url ? [formatDiscordMarkdownLink("Review", data.review.url)] : []), + ]; + appendDiscordField(fields, "Links", links.length > 0 ? links.join(" | ") : undefined, false); +} + +function appendDiscordActionField( + fields: NonNullable, + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): void { + const seen = new Set(); + const links: string[] = []; + + const prUrl = data?.subject.pr?.url; + if (prUrl) { + links.push(formatDiscordMarkdownLink("View PR", prUrl)); + seen.add(prUrl); + } + + const reviewUrl = data?.review?.url; + if (reviewUrl && !seen.has(reviewUrl)) { + links.push(formatDiscordMarkdownLink("View Review", reviewUrl)); + seen.add(reviewUrl); + } + + for (const action of actions ?? []) { + if (action.url) { + if (seen.has(action.url)) continue; + links.push(formatDiscordMarkdownLink(action.label, action.url)); + seen.add(action.url); + continue; + } + if (isAbsoluteHttpUrl(action.callbackEndpoint)) { + links.push(formatDiscordMarkdownLink(action.label, action.callbackEndpoint)); + continue; + } + links.push(`\`${action.label}\``); + } + + appendDiscordField(fields, "Actions", links.slice(0, 8).join(" | "), false); +} + +function buildDiscordComponents( + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): DiscordActionRow[] { + const seen = new Set(); + const buttons: DiscordComponentButton[] = []; + const addButton = (label: string, url: string): void => { + if (seen.has(url) || buttons.length >= 5) return; + buttons.push({ type: 2, style: 5, label: truncateUnicode(label, 80), url }); + seen.add(url); + }; + + const prUrl = data?.subject.pr?.url; + if (prUrl) addButton("View PR", prUrl); + + const reviewUrl = data?.review?.url; + if (reviewUrl) addButton("View Review", reviewUrl); + + for (const action of actions ?? []) { + if (action.url) addButton(action.label, action.url); + else if (isAbsoluteHttpUrl(action.callbackEndpoint)) + addButton(action.label, action.callbackEndpoint); + } + + return buttons.length > 0 ? [{ type: 1, components: buttons }] : []; +} + +function formatDiscordDescription( + event: OrchestratorEvent, + data: NotificationDataV3 | null, +): string { + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const description = subtitle ? `**${subtitle}**\n${event.message}` : event.message; + return truncateUnicode(description, DISCORD_EMBED_DESCRIPTION_MAX); +} + +function formatDiscordFallback(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const tone = discordToneForEvent(event); + return truncateUnicode( + `${tone.label}: ${formatDiscordTitle(event, data)} — ${event.message}`, + 2000, + ); +} + +function formatDiscordMessagePayload( + event: OrchestratorEvent, + actions?: NotifyAction[], +): DiscordMessagePayload { + const data = getNotificationDataV3(event.data); + const tone = discordToneForEvent(event); + const fields: NonNullable = []; + + appendDiscordField(fields, "Project", event.projectId); + appendDiscordField(fields, "Session", event.sessionId); + appendDiscordField(fields, "Priority", tone.label); + appendDiscordDataFields(fields, data); + appendDiscordActionField(fields, data, actions); + + const components = buildDiscordComponents(data, actions); + return { + content: formatDiscordFallback(event, data), + embeds: [ + { + title: truncateUnicode( + `${tone.emoji} ${formatDiscordTitle(event, data)}`, + DISCORD_EMBED_TITLE_MAX, + ), + description: formatDiscordDescription(event, data), + color: tone.color, + ...(data?.subject.pr?.url ? { url: data.subject.pr.url } : {}), + fields, + timestamp: event.timestamp.toISOString(), + footer: { text: "Agent Orchestrator" }, + }, + ], + ...(components.length > 0 ? { components } : {}), + allowed_mentions: { parse: [] }, + }; +} + +const SLACK_SUCCESS_TONE: SlackTone = { + emoji: ":white_check_mark:", + label: "Complete", + color: "#2EB67D", +}; + +const SLACK_PRIORITY_TONE: Record = { + urgent: { + emoji: ":rotating_light:", + label: "Urgent", + color: "#E01E5A", + }, + action: { + emoji: ":point_right:", + label: "Action required", + color: "#6157D8", + }, + warning: { + emoji: ":warning:", + label: "Warning", + color: "#ECB22E", + }, + info: { + emoji: ":information_source:", + label: "Information", + color: "#36C5F0", + }, +}; + +function escapeSlackText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\*/g, "*") + .replace(/_/g, "_") + .replace(/~/g, "~") + .replace(/`/g, "`"); +} + +function formatSlackDate(date: Date): string { + const timestamp = Math.floor(date.getTime() / 1000); + return ``; +} + +function slackToneForEvent(event: OrchestratorEvent): SlackTone { + if (event.type === "merge.ready") return { ...SLACK_SUCCESS_TONE, label: "Ready to merge" }; + if (event.type === "summary.all_complete") + return { ...SLACK_SUCCESS_TONE, label: "All complete" }; + if (event.type === "ci.failing" || event.type === "session.stuck") + return SLACK_PRIORITY_TONE.urgent; + if (event.type === "review.changes_requested") return SLACK_PRIORITY_TONE.warning; + return SLACK_PRIORITY_TONE[event.priority] ?? SLACK_PRIORITY_TONE.info; +} + +function formatSlackTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + return formatDiscordTitle(event, data); +} + +function formatSlackField( + label: string, + value: string | number | boolean | undefined | null, +): unknown { + return { + type: "mrkdwn", + text: `*${escapeSlackText(label)}*\n${escapeSlackText( + value === undefined || value === null || value === "" ? "Not available" : String(value), + )}`, + }; +} + +function buildSlackFieldBlocks( + event: OrchestratorEvent, + data: NotificationDataV3 | null, +): unknown[] { + const pr = data?.subject.pr; + const issue = data?.subject.issue; + const branch = formatDiscordBranch(data); + const fields = [ + formatSlackField("Project", event.projectId), + formatSlackField("Session", event.sessionId), + formatSlackField("Priority", slackToneForEvent(event).label), + ...(pr + ? [formatSlackField("Pull Request", `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}`)] + : []), + ...(branch ? [formatSlackField("Branch", branch)] : []), + ...(issue + ? [formatSlackField("Issue", `${issue.id}${issue.title ? ` - ${issue.title}` : ""}`)] + : []), + ...(data?.ci?.status ? [formatSlackField("CI", titleCaseStatus(data.ci.status))] : []), + ...(data?.review?.decision + ? [formatSlackField("Review", titleCaseStatus(data.review.decision))] + : []), + ...(typeof data?.merge?.ready === "boolean" + ? [formatSlackField("Merge", data.merge.ready ? "Ready" : "Not ready")] + : []), + ...(typeof data?.merge?.isBehind === "boolean" + ? [formatSlackField("Sync", data.merge.isBehind ? "Behind base" : "Up to date")] + : []), + ].slice(0, 10); + + return fields.length > 0 ? [{ type: "section", fields }] : []; +} + +function buildSlackStatusContext(data: NotificationDataV3 | null): unknown[] { + if (!data) return []; + const context: string[] = []; + + if (data.ci?.status) { + const ciEmoji = data.ci.status === "passing" ? ":white_check_mark:" : ":x:"; + const failedChecks = data.ci.failedChecks?.map((check) => escapeSlackText(check.name)) ?? []; + const failedText = failedChecks.length > 0 ? ` | Failed: ${failedChecks.join(", ")}` : ""; + context.push(`${ciEmoji} CI: ${escapeSlackText(data.ci.status)}${failedText}`); + } + + if (typeof data.merge?.conflicts === "boolean") { + context.push( + data.merge.conflicts + ? ":x: Merge conflicts detected" + : ":white_check_mark: No merge conflicts", + ); + } + + if (typeof data.review?.unresolvedThreads === "number") { + context.push(`:speech_balloon: Review threads: ${data.review.unresolvedThreads}`); + } + + if (data.merge?.blockers?.length) { + context.push( + `:no_entry: Blockers: ${data.merge.blockers.slice(0, 5).map(escapeSlackText).join(", ")}`, + ); + } + + if (context.length === 0) return []; + return [ + { + type: "context", + elements: [{ type: "mrkdwn", text: context.join(" • ") }], + }, + ]; +} + +function sanitizeSlackActionId(label: string, index: number): string { + const sanitized = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, ""); + return `ao_${sanitized ? `${sanitized}_${index}` : `action_${index}`}`; +} + +function buildSlackButton(label: string, url: string, style?: "primary" | "danger"): SlackButton { + return { + type: "button", + text: { type: "plain_text", text: truncateUnicode(label, 75), emoji: true }, + url, + ...(style ? { style } : {}), + }; +} + +function buildSlackActionElements( + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): SlackButton[] { + const elements: SlackButton[] = []; + const seenUrls = new Set(); + const prUrl = data?.subject.pr?.url; + const reviewUrl = data?.review?.url; + + if (prUrl) { + elements.push(buildSlackButton("View PR", prUrl, "primary")); + seenUrls.add(prUrl); + } + + if (reviewUrl && !seenUrls.has(reviewUrl)) { + elements.push(buildSlackButton("View Review", reviewUrl)); + seenUrls.add(reviewUrl); + } + + for (const [index, action] of (actions ?? []).entries()) { + if (action.url) { + if (seenUrls.has(action.url)) continue; + elements.push( + buildSlackButton(action.label, action.url, elements.length === 0 ? "primary" : undefined), + ); + seenUrls.add(action.url); + continue; + } + if (!action.callbackEndpoint) continue; + + const label = truncateUnicode(action.label, 75); + const lower = label.toLowerCase(); + elements.push({ + type: "button", + text: { type: "plain_text", text: label, emoji: true }, + action_id: sanitizeSlackActionId(label, index), + value: action.callbackEndpoint, + ...(lower.includes("kill") || lower.includes("cancel") ? { style: "danger" } : {}), + }); + } + + return elements.slice(0, 5); +} + +function buildSlackAttachment(event: OrchestratorEvent, actions?: NotifyAction[]): SlackAttachment { + const data = getNotificationDataV3(event.data); + const tone = slackToneForEvent(event); + const title = formatSlackTitle(event, data); + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const blocks: unknown[] = [ + { + type: "header", + text: { + type: "plain_text", + text: truncateUnicode(`${tone.emoji} ${title}`, 150), + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `${subtitle ? `*${escapeSlackText(subtitle)}*\n` : ""}${escapeSlackText(event.message)}`, + }, + }, + ...buildSlackFieldBlocks(event, data), + ...buildSlackStatusContext(data), + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Sent by Agent Orchestrator • ${formatSlackDate(event.timestamp)}`, + }, + ], + }, + ]; + + const actionElements = buildSlackActionElements(data, actions); + if (actionElements.length > 0) { + blocks.push({ + type: "actions", + elements: actionElements, + }); + } + + blocks.push({ type: "divider" }); + + return { + color: tone.color, + fallback: `${tone.label}: ${title} — ${event.message}`, + blocks, + }; +} + +function formatSlackMessagePayload( + event: OrchestratorEvent, + actions?: NotifyAction[], +): SlackMessagePayload { + const attachment = buildSlackAttachment(event, actions); + return { + markdown_text: attachment.fallback, + text: attachment.fallback, + attachments: JSON.stringify([attachment]), + unfurl_links: false, + unfurl_media: false, + }; +} + +function formatEmailSubject(event: OrchestratorEvent): string { + const data = getNotificationDataV3(event.data); + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `[AO] CI failing on PR #${pr.number}` : "[AO] CI failing"; + case "merge.ready": + return pr ? `[AO] PR #${pr.number} ready to merge` : "[AO] Merge ready"; + case "review.changes_requested": + return pr ? `[AO] Changes requested on PR #${pr.number}` : "[AO] Review changes requested"; + case "session.needs_input": + return `[AO] Agent needs input: ${event.sessionId}`; + case "session.stuck": + return `[AO] Agent stuck: ${event.sessionId}`; + case "session.killed": + case "session.exited": + return `[AO] Agent exited: ${event.sessionId}`; + case "pr.closed": + return pr ? `[AO] PR #${pr.number} closed` : "[AO] PR closed"; + case "summary.all_complete": + return "[AO] All sessions complete"; + default: + return `[AO] ${titleCaseStatus(event.type)}: ${event.sessionId}`; + } +} + +const HTML_ESCAPE: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE[char] ?? char); +} + +function formatHtmlValue(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null || value === "") return "Not available"; + return escapeHtml(String(value)); +} + +interface EmailTone { + label: string; + headerBackground: string; + accent: string; + badgeBackground: string; + badgeText: string; +} + +interface HtmlStatusCard { + label: string; + value: string; + color?: string; + background?: string; +} + +const EMAIL_TONES: Record<"info" | "action" | "warning" | "urgent" | "success", EmailTone> = { + info: { + label: "Information", + headerBackground: "#0f172a", + accent: "#2563eb", + badgeBackground: "#dbeafe", + badgeText: "#1e40af", + }, + action: { + label: "Action required", + headerBackground: "#1e3a8a", + accent: "#4f46e5", + badgeBackground: "#e0e7ff", + badgeText: "#3730a3", + }, + warning: { + label: "Warning", + headerBackground: "#78350f", + accent: "#d97706", + badgeBackground: "#fef3c7", + badgeText: "#92400e", + }, + urgent: { + label: "Urgent", + headerBackground: "#7f1d1d", + accent: "#dc2626", + badgeBackground: "#fee2e2", + badgeText: "#991b1b", + }, + success: { + label: "Complete", + headerBackground: "#064e3b", + accent: "#16a34a", + badgeBackground: "#dcfce7", + badgeText: "#166534", + }, +}; + +function emailToneForEvent(event: OrchestratorEvent): EmailTone { + if (event.type === "merge.ready" || event.type === "summary.all_complete") { + return { + ...EMAIL_TONES.success, + label: event.type === "merge.ready" ? "Ready to merge" : "All complete", + }; + } + if (event.type === "ci.failing" || event.type === "session.stuck") return EMAIL_TONES.urgent; + if (event.type === "review.changes_requested" || event.priority === "warning") { + return EMAIL_TONES.warning; + } + if (event.priority === "action") return EMAIL_TONES.action; + if (event.priority === "urgent") return EMAIL_TONES.urgent; + return EMAIL_TONES.info; +} + +function formatEmailTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI is failing on PR #${pr.number}` : "CI is failing"; + case "merge.ready": + return pr ? `PR #${pr.number} is ready to merge` : "Pull request is ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function formatEmailSubtitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + return data?.subject.pr?.title ?? data?.subject.summary ?? event.message; +} + +function formatHtmlStatusCard(card: HtmlStatusCard, fallback: EmailTone): string { + return ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
+ `; +} + +function formatHtmlStatusCards(cards: HtmlStatusCard[], tone: EmailTone): string { + if (cards.length === 0) return ""; + + const rows: string[] = []; + for (let index = 0; index < cards.length; index += 2) { + const first = cards[index]; + const second = cards[index + 1]; + rows.push(` + ${formatHtmlStatusCard(first, tone)} + ${second ? formatHtmlStatusCard(second, tone) : ''} + `); + } + + return ` + + + ${rows.join("")} +
+ + `; +} + +function formatHtmlDetailRow( + label: string, + value: string | number | boolean | undefined | null, +): string { + return ` + ${escapeHtml(label)} + ${formatHtmlValue(value)} + `; +} + +function formatHtmlDetails( + rows: Array<[string, string | number | boolean | undefined | null]>, +): string { + const renderedRows = rows + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([label, value]) => formatHtmlDetailRow(label, value)); + + if (renderedRows.length === 0) return ""; + + return `
+
Details
+ + ${renderedRows.join("")} +
+
`; +} + +function formatHtmlList(title: string, items: string[]): string { + const filtered = items.filter(Boolean); + if (filtered.length === 0) return ""; + + return `
+
${escapeHtml(title)}
+
    + ${filtered.map((item) => `
  • ${item}
  • `).join("")} +
+
`; +} + +function formatHtmlCheck(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const label = `${check.name}: ${status}`; + if (!check.url) return escapeHtml(label); + return `${escapeHtml(label)}`; +} + +function formatHtmlContextList(data: Record): string { + const items = Object.entries(data) + .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) + .slice(0, 8) + .map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(truncate(String(value)))}`); + + return formatHtmlList("Context", items); +} + +function formatHtmlActionButtons(actions: NotifyAction[] | undefined): string { + if (!actions || actions.length === 0) return ""; + + const buttons = actions + .map((action) => { + const target = action.url ?? action.callbackEndpoint; + if (!target) { + return `${escapeHtml(action.label)}`; + } + + return `${escapeHtml(action.label)}`; + }) + .join(""); + + return `
+
Actions
+ ${buttons} +
`; +} + +function buildEmailStatusCards( + event: OrchestratorEvent, + data: NotificationDataV3 | null, +): HtmlStatusCard[] { + const cards: HtmlStatusCard[] = [ + { label: "Status", value: priorityLabel(event.priority) }, + { label: "Event", value: titleCaseStatus(event.type) }, + ]; + + if (!data) return cards; + + if (data.ci?.status) { + const failing = data.ci.status === "failing"; + cards.push({ + label: "CI", + value: titleCaseStatus(data.ci.status), + color: failing ? "#991b1b" : "#166534", + background: failing ? "#fee2e2" : "#dcfce7", + }); + } + + if (data.review?.decision) { + const approved = data.review.decision === "approved"; + cards.push({ + label: "Review", + value: titleCaseStatus(data.review.decision), + color: approved ? "#166534" : "#92400e", + background: approved ? "#dcfce7" : "#fef3c7", + }); + } + + if (typeof data.merge?.ready === "boolean") { + cards.push({ + label: "Merge", + value: data.merge.ready ? "Ready" : "Not ready", + color: data.merge.ready ? "#166534" : "#92400e", + background: data.merge.ready ? "#dcfce7" : "#fef3c7", + }); + } + + if (typeof data.merge?.conflicts === "boolean") { + cards.push({ + label: "Conflicts", + value: data.merge.conflicts ? "Found" : "None", + color: data.merge.conflicts ? "#991b1b" : "#166534", + background: data.merge.conflicts ? "#fee2e2" : "#dcfce7", + }); + } + + if (typeof data.merge?.isBehind === "boolean") { + cards.push({ + label: "Sync", + value: data.merge.isBehind ? "Behind base" : "Up to date", + color: data.merge.isBehind ? "#92400e" : "#166534", + background: data.merge.isBehind ? "#fef3c7" : "#dcfce7", + }); + } + + if (typeof data.review?.unresolvedThreads === "number") { + cards.push({ + label: "Threads", + value: String(data.review.unresolvedThreads), + color: data.review.unresolvedThreads > 0 ? "#92400e" : "#166534", + background: data.review.unresolvedThreads > 0 ? "#fef3c7" : "#dcfce7", + }); + } + + return cards.slice(0, 8); +} + +function formatEmailHtml(event: OrchestratorEvent, actions?: NotifyAction[]): string { + const data = getNotificationDataV3(event.data); + const tone = emailToneForEvent(event); + const pr = data?.subject.pr; + const issue = data?.subject.issue; + const title = formatEmailTitle(event, data); + const subtitle = formatEmailSubtitle(event, data); + const branchLine = + pr?.branch && pr.baseBranch + ? `${pr.branch} -> ${pr.baseBranch}` + : (pr?.branch ?? pr?.baseBranch); + const primaryUrl = pr?.url ?? data?.review?.url; + const primaryLabel = pr?.url ? "View pull request" : data?.review?.url ? "View review" : ""; + const primaryCta = primaryUrl + ? `${escapeHtml(primaryLabel)}` + : ""; + const detailRows: Array<[string, string | number | boolean | undefined | null]> = [ + ["Project", event.projectId], + ["Session", event.sessionId], + ["Pull Request", pr ? `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}` : undefined], + ["Branch", branchLine], + ["Issue", issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined], + [ + "Transition", + data?.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined, + ], + ["Reaction", data?.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined], + [ + "Escalation", + data?.escalation + ? `${data.escalation.attempts} attempts (${data.escalation.cause})` + : undefined, + ], + ["Time", event.timestamp.toISOString()], + ]; + const checks = (data?.ci?.failedChecks ?? []).slice(0, 10).map(formatHtmlCheck); + const blockers = (data?.merge?.blockers ?? []).slice(0, 10).map(escapeHtml); + const links = [ + ...(pr?.url + ? [ + `Pull request`, + ] + : []), + ...(data?.review?.url + ? [ + `Review`, + ] + : []), + ]; + + return ` + + + + + ${escapeHtml(formatEmailSubject(event))} + + + + + + +
+ + + + + + + + ${formatHtmlStatusCards(buildEmailStatusCards(event, data), tone)} + + + + + + +
+
${escapeHtml(tone.label)}
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+
+

${escapeHtml(event.message)}

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

Message

+
${escapeHtml(message)}
+
+ +`; +} + +function isHtmlEmailBody(body: string): boolean { + return /^\s*(?:])/i.test(body); +} + +function normalizeSlackChannel(channel: string | undefined): string | undefined { + return channel?.replace(/^#/, ""); +} + +function formatUnknownError(value: unknown): string { + if (value instanceof Error) { + const cause = (value as Error & { cause?: unknown }).cause; + if (cause !== undefined) { + const causeMessage = formatUnknownError(cause); + if (causeMessage && !value.message.includes(causeMessage)) { + return `${value.message}: ${causeMessage}`; + } + } + return value.message; + } + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function formatComposioError(err: unknown, app: ComposioApp, discordMode?: DiscordMode): Error { + const message = formatUnknownError(err); + const lower = message.toLowerCase(); + if (lower.includes("connected account") || lower.includes("could not find a connection")) { + const setupCommand = setupCommandForApp(app, discordMode); + if (app === "discord" && discordMode === "webhook") { + return new Error( + `[notifier-composio] ${message}. Run \`${setupCommand}\` to create or refresh the Discord webhook connected account for this userId.`, + ); + } + return new Error( + `[notifier-composio] ${message}. Run \`${setupCommand}\`, connect ${app} in Composio, or set connectedAccountId / userId. entityId is still supported as an alias for userId.`, + ); + } + + return err instanceof Error ? err : new Error(message); +} + +function setupCommandForApp(app: ComposioApp, discordMode?: DiscordMode): string { + if (app === "discord") { + return discordMode === "webhook" + ? "ao setup composio-discord" + : "ao setup composio-discord-bot"; + } + if (app === "gmail") return "ao setup composio-mail"; + return "ao setup composio"; +} + function buildToolArgs( app: ComposioApp, + discordMode: DiscordMode | undefined, text: string, channelId?: string, channelName?: string, emailTo?: string, + webhookUrl?: string, + emailSubject: string = GMAIL_SUBJECT, + discordPayload?: DiscordMessagePayload, + slackPayload?: SlackMessagePayload, ): Record { if (app === "slack") { - const args: Record = { text }; - if (channelId) args.channel = channelId; - else if (channelName) args.channel = channelName; + const args: Record = slackPayload + ? { ...slackPayload } + : { markdown_text: text }; + const channel = channelId ?? normalizeSlackChannel(channelName); + if (channel) args.channel = channel; return args; } if (app === "discord") { - const args: Record = { content: text }; - // Discord requires numeric channel IDs — channelName is not supported + const messagePayload: Record = discordPayload + ? { ...discordPayload } + : { + content: text, + allowed_mentions: { parse: [] }, + }; + + if (discordMode === "webhook") { + if (!webhookUrl) { + throw new Error( + '[notifier-composio] webhookUrl is required when defaultApp is "discord" and mode is "webhook"', + ); + } + const parsed = parseDiscordWebhookUrl(webhookUrl); + return { + webhook_id: parsed.webhookId, + webhook_token: parsed.webhookToken, + ...messagePayload, + }; + } + + const args: Record = { ...messagePayload }; + // Discord requires numeric channel IDs — channelName is accepted as a manual fallback. if (channelId) args.channel_id = channelId; else if (channelName) args.channel_id = channelName; + else { + throw new Error( + '[notifier-composio] channelId is required when defaultApp is "discord" and mode is "bot"', + ); + } return args; } - // gmail — emailTo is required, validated at config time return { - to: emailTo ?? "", - subject: GMAIL_SUBJECT, + recipient_email: emailTo ?? "", + subject: emailSubject, body: text, + ...(isHtmlEmailBody(text) ? { is_html: true } : {}), }; } +function resolveToolVersion( + config: Record | undefined, + app: ComposioApp, +): string | undefined { + const toolVersions = config?.["toolVersions"]; + if (toolVersions && typeof toolVersions === "object") { + const appVersion = (toolVersions as Record)[app]; + if (typeof appVersion === "string" && appVersion.trim().length > 0) { + return appVersion; + } + } + + return stringConfig(config, "toolVersion") ?? DEFAULT_TOOL_VERSION[app]; +} + +function resolveToolSlug(app: ComposioApp, discordMode: DiscordMode | undefined): string { + if (app === "discord" && discordMode === "webhook") return DISCORD_WEBHOOK_TOOL_SLUG; + return APP_TOOL_SLUG[app]; +} + export function create(config?: Record): Notifier { const apiKey = - (typeof config?.composioApiKey === "string" ? config.composioApiKey : undefined) ?? - process.env.COMPOSIO_API_KEY; + resolveEnvReference(stringConfig(config, "composioApiKey")) ?? process.env.COMPOSIO_API_KEY; const defaultApp: ComposioApp = typeof config?.defaultApp === "string" && VALID_APPS.has(config.defaultApp) ? (config.defaultApp as ComposioApp) : "slack"; - const channelName = typeof config?.channelName === "string" ? config.channelName : undefined; - const channelId = typeof config?.channelId === "string" ? config.channelId : undefined; - const emailTo = typeof config?.emailTo === "string" ? config.emailTo : undefined; + const channelName = stringConfig(config, "channelName"); + const channelId = stringConfig(config, "channelId"); + const webhookUrl = resolveEnvReference(stringConfig(config, "webhookUrl")); + const discordMode = resolveDiscordMode(config, defaultApp, webhookUrl); + const userId = + stringConfig(config, "userId") ?? + stringConfig(config, "entityId") ?? + process.env.COMPOSIO_USER_ID ?? + process.env.COMPOSIO_ENTITY_ID ?? + DEFAULT_COMPOSIO_USER_ID; + const emailTo = stringConfig(config, "emailTo"); + const toolVersion = resolveToolVersion(config, defaultApp); + const forceSkipVersionCheck = boolConfig(config, "dangerouslySkipVersionCheck"); + const connectedAccountId = stringConfig(config, "connectedAccountId"); - // Internal: allows tests to inject a mock client without mocking composio-core const clientOverride = - config?._clientOverride !== undefined && - config._clientOverride !== null && - typeof (config._clientOverride as ComposioToolkit).executeAction === "function" - ? (config._clientOverride as ComposioToolkit) + config?._clientOverride !== undefined && config._clientOverride !== null + ? config._clientOverride : undefined; + if (clientOverride !== undefined && !isComposioToolsClient(clientOverride)) { + throw new Error("[notifier-composio] _clientOverride must expose tools.execute()"); + } + if (typeof config?.defaultApp === "string" && !VALID_APPS.has(config.defaultApp)) { throw new Error( `[notifier-composio] Invalid defaultApp: "${config.defaultApp}". Must be one of: slack, discord, gmail`, @@ -184,13 +1573,21 @@ export function create(config?: Record): Notifier { throw new Error('[notifier-composio] emailTo is required when defaultApp is "gmail"'); } - let client: ComposioToolkit | null | undefined = clientOverride; + if (defaultApp === "discord" && discordMode === "webhook" && !webhookUrl) { + throw new Error( + '[notifier-composio] webhookUrl is required when defaultApp is "discord" and mode is "webhook"', + ); + } + + let client: ComposioToolsClient | null | undefined = clientOverride as + | ComposioToolsClient + | undefined; let warnedNoKey = false; + let warnedSkipVersion = false; let sdkMissing = false; - async function getClient(): Promise { - // If a client override was injected, always use it - if (clientOverride) return clientOverride; + async function getClient(): Promise { + if (clientOverride) return clientOverride as ComposioToolsClient; if (!apiKey) { if (!warnedNoKey) { @@ -208,9 +1605,8 @@ export function create(config?: Record): Notifier { client = await loadComposioSDK(apiKey); if (client === null) { sdkMissing = true; - // eslint-disable-next-line no-console console.warn( - "[notifier-composio] composio-core package is not installed — notifications will be no-ops. Run: npm install composio-core", + "[notifier-composio] @composio/core package is not installed — notifications will be no-ops.", ); return null; } @@ -220,15 +1616,29 @@ export function create(config?: Record): Notifier { } async function executeWithTimeout( - composio: ComposioToolkit, + composio: ComposioToolsClient, action: string, - params: Record, + args: Record, ): Promise { const timeoutMs = 30_000; const timeoutSignal = AbortSignal.timeout(timeoutMs); + const executeParams: ComposioExecuteParams = { + userId, + arguments: args, + ...(connectedAccountId ? { connectedAccountId } : {}), + ...(toolVersion ? { version: toolVersion } : { dangerouslySkipVersionCheck: true }), + ...(forceSkipVersionCheck ? { dangerouslySkipVersionCheck: true } : {}), + }; - const actionPromise = composio.executeAction({ action, params }); - // Prevent unhandled rejection if the timeout fires and actionPromise later rejects + if (!toolVersion && !warnedSkipVersion) { + console.warn( + `[notifier-composio] No toolVersion configured for ${defaultApp}; using Composio latest-version execution.`, + ); + warnedSkipVersion = true; + } + + const actionPromise = composio.tools.execute(action, executeParams); + // Prevent unhandled rejection if the timeout fires and actionPromise later rejects. actionPromise.catch(() => {}); const result = await Promise.race([ @@ -246,11 +1656,21 @@ export function create(config?: Record): Notifier { { once: true }, ); }), - ]); + ]).catch((err: unknown) => { + throw formatComposioError(err, defaultApp, discordMode); + }); - if (!result.successful) { + if (result.successful === false) { throw new Error( - `[notifier-composio] Composio action ${action} failed: ${result.error ?? "unknown error"}`, + `[notifier-composio] Composio action ${action} failed: ${formatUnknownError(result.error ?? "unknown error")}`, + ); + } + } + + function assertGmailConnectedAccount(): void { + if (defaultApp === "gmail" && !connectedAccountId) { + throw new Error( + '[notifier-composio] connectedAccountId is required when defaultApp is "gmail". Connect Gmail in Composio, then run `ao setup composio-mail`, or set notifiers..connectedAccountId.', ); } } @@ -261,10 +1681,26 @@ export function create(config?: Record): Notifier { async notify(event: OrchestratorEvent): Promise { const composio = await getClient(); if (!composio) return; + assertGmailConnectedAccount(); - const text = formatNotifyText(event); - const toolSlug = APP_TOOL_SLUG[defaultApp]; - const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo); + const text = defaultApp === "gmail" ? formatEmailBody(event) : formatNotifyText(event); + const emailSubject = defaultApp === "gmail" ? formatEmailSubject(event) : undefined; + const discordPayload = + defaultApp === "discord" ? formatDiscordMessagePayload(event) : undefined; + const slackPayload = defaultApp === "slack" ? formatSlackMessagePayload(event) : undefined; + const toolSlug = resolveToolSlug(defaultApp, discordMode); + const args = buildToolArgs( + defaultApp, + discordMode, + text, + channelId, + channelName, + emailTo, + webhookUrl, + emailSubject, + discordPayload, + slackPayload, + ); await executeWithTimeout(composio, toolSlug, args); }, @@ -272,10 +1708,30 @@ export function create(config?: Record): Notifier { async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { const composio = await getClient(); if (!composio) return; + assertGmailConnectedAccount(); - const text = formatActionsText(event, actions); - const toolSlug = APP_TOOL_SLUG[defaultApp]; - const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo); + const text = + defaultApp === "gmail" + ? formatEmailBody(event, actions) + : formatActionsText(event, actions); + const emailSubject = defaultApp === "gmail" ? formatEmailSubject(event) : undefined; + const discordPayload = + defaultApp === "discord" ? formatDiscordMessagePayload(event, actions) : undefined; + const slackPayload = + defaultApp === "slack" ? formatSlackMessagePayload(event, actions) : undefined; + const toolSlug = resolveToolSlug(defaultApp, discordMode); + const args = buildToolArgs( + defaultApp, + discordMode, + text, + channelId, + channelName, + emailTo, + webhookUrl, + emailSubject, + discordPayload, + slackPayload, + ); await executeWithTimeout(composio, toolSlug, args); }, @@ -283,16 +1739,31 @@ export function create(config?: Record): Notifier { async post(message: string, context?: NotifyContext): Promise { const composio = await getClient(); if (!composio) return null; + assertGmailConnectedAccount(); const channel = context?.channel ?? channelId ?? channelName; - const toolSlug = APP_TOOL_SLUG[defaultApp]; + const slackChannel = normalizeSlackChannel(channel); + const toolSlug = resolveToolSlug(defaultApp, discordMode); const args: Record = defaultApp === "gmail" - ? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message } + ? { + recipient_email: emailTo ?? "", + subject: GMAIL_POST_SUBJECT, + body: formatPostEmailBody(message), + is_html: true, + } : defaultApp === "discord" - ? { content: message, ...(channel ? { channel_id: channel } : {}) } - : { text: message, ...(channel ? { channel } : {}) }; + ? buildToolArgs( + defaultApp, + discordMode, + message, + discordMode === "bot" ? channel : channelId, + channelName, + emailTo, + webhookUrl, + ) + : { markdown_text: message, ...(slackChannel ? { channel: slackChannel } : {}) }; await executeWithTimeout(composio, toolSlug, args); return null; diff --git a/packages/plugins/notifier-dashboard/package.json b/packages/plugins/notifier-dashboard/package.json new file mode 100644 index 000000000..5bb7ac9c0 --- /dev/null +++ b/packages/plugins/notifier-dashboard/package.json @@ -0,0 +1,44 @@ +{ + "name": "@aoagents/ao-plugin-notifier-dashboard", + "version": "0.6.0", + "description": "Notifier plugin: AO dashboard notifications", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/notifier-dashboard" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@aoagents/ao-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/notifier-dashboard/src/index.test.ts b/packages/plugins/notifier-dashboard/src/index.test.ts new file mode 100644 index 000000000..862f95118 --- /dev/null +++ b/packages/plugins/notifier-dashboard/src/index.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildSessionTransitionNotificationData, + getDashboardNotificationStorePath, + readDashboardNotifications, + type OrchestratorEvent, +} from "@aoagents/ao-core"; +import { create, manifest } from "./index.js"; + +let tempDir: string | null = null; + +function makeConfigPath(): string { + tempDir = mkdtempSync(join(tmpdir(), "ao-dashboard-plugin-")); + return join(tempDir, "agent-orchestrator.yaml"); +} + +function makeEvent(overrides: Partial = {}): OrchestratorEvent { + return { + id: "evt-1", + type: "session.needs_input", + priority: "action", + sessionId: "worker-1", + projectId: "demo", + timestamp: new Date("2026-05-13T12:00:00.000Z"), + message: "Agent needs input", + data: buildSessionTransitionNotificationData({ + eventType: "session.needs_input", + sessionId: "worker-1", + projectId: "demo", + context: { + pr: { + number: 1, + url: "https://github.com/acme/app/pull/1", + title: "Demo PR", + branch: "demo/pr", + baseBranch: "main", + owner: "acme", + repo: "app", + isDraft: false, + }, + issueId: null, + issueTitle: null, + summary: "Demo session", + branch: "demo/pr", + }, + oldStatus: "working", + newStatus: "needs_input", + }), + ...overrides, + }; +} + +afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + vi.restoreAllMocks(); +}); + +describe("notifier-dashboard", () => { + it("has dashboard notifier metadata", () => { + expect(manifest.name).toBe("dashboard"); + expect(manifest.slot).toBe("notifier"); + }); + + it("persists notifications to the config-specific dashboard store", async () => { + const configPath = makeConfigPath(); + const notifier = create({ configPath, limit: 50 }); + + await notifier.notify(makeEvent()); + + const records = readDashboardNotifications(configPath); + expect(records).toHaveLength(1); + expect(records[0].event.sessionId).toBe("worker-1"); + expect(records[0].event.data).toMatchObject({ + schemaVersion: 3, + subject: { pr: { url: "https://github.com/acme/app/pull/1" } }, + }); + }); + + it("persists actions with notifications", async () => { + const configPath = makeConfigPath(); + const notifier = create({ configPath }); + + await notifier.notifyWithActions?.(makeEvent(), [ + { label: "Open PR", url: "https://github.com/acme/app/pull/1" }, + ]); + + const records = readDashboardNotifications(configPath); + expect(records[0].actions).toEqual([ + { label: "Open PR", url: "https://github.com/acme/app/pull/1" }, + ]); + }); + + it("retains only the configured limit", async () => { + const configPath = makeConfigPath(); + const notifier = create({ configPath, limit: 2 }); + + await notifier.notify(makeEvent({ id: "evt-1" })); + await notifier.notify(makeEvent({ id: "evt-2" })); + await notifier.notify(makeEvent({ id: "evt-3" })); + + expect(readDashboardNotifications(configPath, 50).map((record) => record.event.id)).toEqual([ + "evt-2", + "evt-3", + ]); + }); + + it("warns and no-ops when configPath is missing", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const notifier = create(); + + await notifier.notify(makeEvent()); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No configPath available")); + }); + + it("uses the expected store path shape", () => { + const configPath = makeConfigPath(); + expect(getDashboardNotificationStorePath(configPath)).toContain( + "dashboard-notifications.jsonl", + ); + }); +}); diff --git a/packages/plugins/notifier-dashboard/src/index.ts b/packages/plugins/notifier-dashboard/src/index.ts new file mode 100644 index 000000000..e8296ecc5 --- /dev/null +++ b/packages/plugins/notifier-dashboard/src/index.ts @@ -0,0 +1,53 @@ +import { + appendDashboardNotification, + normalizeDashboardNotificationLimit, + type Notifier, + type NotifyAction, + type OrchestratorEvent, + type PluginModule, +} from "@aoagents/ao-core"; + +export const manifest = { + name: "dashboard", + slot: "notifier" as const, + description: "Notifier plugin: AO dashboard notifications", + version: "0.1.0", +}; + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function create(config?: Record): Notifier { + const configPath = stringValue(config?.configPath); + const limit = normalizeDashboardNotificationLimit(config?.limit); + let warnedMissingConfigPath = false; + + function persist(event: OrchestratorEvent, actions?: NotifyAction[]): void { + if (!configPath) { + if (!warnedMissingConfigPath) { + console.warn( + "[notifier-dashboard] No configPath available - dashboard notifications will be no-ops", + ); + warnedMissingConfigPath = true; + } + return; + } + + appendDashboardNotification(configPath, event, actions, { limit }); + } + + return { + name: "dashboard", + + async notify(event: OrchestratorEvent): Promise { + persist(event); + }, + + async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { + persist(event, actions); + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/notifier-dashboard/tsconfig.json b/packages/plugins/notifier-dashboard/tsconfig.json new file mode 100644 index 000000000..e434eed50 --- /dev/null +++ b/packages/plugins/notifier-dashboard/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/plugins/notifier-desktop/src/index.test.ts b/packages/plugins/notifier-desktop/src/index.test.ts index 6abf9bae1..1f7755c41 100644 --- a/packages/plugins/notifier-desktop/src/index.test.ts +++ b/packages/plugins/notifier-desktop/src/index.test.ts @@ -1,22 +1,37 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; import type { OrchestratorEvent, NotifyAction } from "@aoagents/ao-core"; // Mock node:child_process vi.mock("node:child_process", () => ({ execFile: vi.fn(), + execFileSync: vi.fn(), +})); + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => false), })); // Mock node:os vi.mock("node:os", () => ({ + homedir: vi.fn(() => "/Users/test"), platform: vi.fn(() => "darwin"), })); -import { execFile } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { platform } from "node:os"; import { manifest, create, escapeAppleScript } from "./index.js"; const mockExecFile = execFile as unknown as Mock; +const mockExecFileSync = execFileSync as unknown as Mock; +const mockExistsSync = existsSync as unknown as Mock; const mockPlatform = platform as unknown as Mock; +const originalProcessPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + +function setProcessPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} function makeEvent(overrides: Partial = {}): OrchestratorEvent { return { @@ -36,6 +51,14 @@ describe("notifier-desktop", () => { beforeEach(() => { vi.clearAllMocks(); mockPlatform.mockReturnValue("darwin"); + setProcessPlatform("darwin"); + mockExistsSync.mockReturnValue(false); + // Default: terminal-notifier not available (osascript fallback) + mockExecFileSync.mockImplementation(() => { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); mockExecFile.mockImplementation((..._args: unknown[]) => { // execFile may be called as (cmd, args, cb) or (cmd, args, opts, cb). // Pick whichever trailing arg is the callback so both shapes work. @@ -46,6 +69,12 @@ describe("notifier-desktop", () => { }); }); + afterEach(() => { + if (originalProcessPlatform) { + Object.defineProperty(process, "platform", originalProcessPlatform); + } + }); + describe("manifest", () => { it("has correct metadata", () => { expect(manifest.name).toBe("desktop"); @@ -95,7 +124,7 @@ describe("notifier-desktop", () => { expect(mockExecFile.mock.calls[0][1][0]).toBe("-e"); }); - it("includes session ID in title", async () => { + it("includes session ID in notification subtitle", async () => { const notifier = create(); await notifier.notify(makeEvent({ sessionId: "backend-5" })); @@ -119,12 +148,12 @@ describe("notifier-desktop", () => { expect(script).toContain("URGENT"); }); - it("uses 'Agent Orchestrator' prefix for non-urgent priority", async () => { + it("uses event-aware titles for non-urgent priority", async () => { const notifier = create(); await notifier.notify(makeEvent({ priority: "action" })); const script = mockExecFile.mock.calls[0][1][1] as string; - expect(script).toContain("Agent Orchestrator"); + expect(script).toContain("Session Spawned"); }); it("includes sound for urgent notifications", async () => { @@ -179,6 +208,50 @@ describe("notifier-desktop", () => { expect(script).toContain('\\"quotes\\"'); expect(script).toContain("\\\\backslash"); }); + + it("formats v3 pull request context into a compact desktop summary", async () => { + const notifier = create(); + await notifier.notify( + makeEvent({ + type: "merge.ready", + priority: "action", + projectId: "demo", + sessionId: "demo-agent-29", + message: "PR #1579 is ready to merge", + data: { + schemaVersion: 3, + subject: { + session: { id: "demo-agent-29", projectId: "demo" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + branch: "ao/demo-notifier-harness", + baseBranch: "main", + }, + issue: { id: "AO-1579", title: "Make AO notification payloads API-grade" }, + }, + ci: { status: "passing" }, + review: { decision: "approved" }, + merge: { ready: true, conflicts: false }, + transition: { kind: "pr_state", from: "approved", to: "mergeable" }, + }, + }), + ); + + const script = mockExecFile.mock.calls[0][1][1] as string; + expect(script).toContain("PR #1579 ready to merge"); + expect(script).toContain("Normalize AO notifier payloads"); + expect(script).toContain("demo · demo-agent-29 · PR #1579"); + expect(script).toContain("PR #1579"); + expect(script).toContain("AO-1579"); + expect(script).toContain("Branch: ao/demo-notifier-harness → main"); + expect(script).toContain("CI: Passing"); + expect(script).toContain("Review: Approved"); + expect(script).toContain("Merge: Ready"); + expect(script).toContain("Conflicts: None"); + expect(script).toContain("Transition: approved → mergeable"); + }); }); describe("notify on Linux", () => { @@ -319,4 +392,222 @@ describe("notifier-desktop", () => { await expect(notifier.notify(makeEvent())).rejects.toThrow("osascript not found"); }); }); + + describe("terminal-notifier on macOS", () => { + beforeEach(() => { + // terminal-notifier is available + mockExecFileSync.mockReturnValue(Buffer.from("/usr/local/bin/terminal-notifier\n")); + }); + + it("uses terminal-notifier when available", async () => { + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile).toHaveBeenCalledOnce(); + expect(mockExecFile.mock.calls[0][0]).toBe("terminal-notifier"); + }); + + it("passes -title, -subtitle, and -message args", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ sessionId: "s-1", message: "hello" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-title"); + expect(args).toContain("-subtitle"); + expect(args).toContain("-message"); + expect(args[args.indexOf("-subtitle") + 1]).toBe("my-project · s-1 · Info"); + expect(args[args.indexOf("-message") + 1]).toContain("hello"); + }); + + it("passes session deep link with dashboardUrl when configured", async () => { + const notifier = create({ dashboardUrl: "http://localhost:8080" }); + await notifier.notify(makeEvent()); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-open"); + expect(args[args.indexOf("-open") + 1]).toBe( + "http://localhost:8080/projects/my-project/sessions/app-1", + ); + }); + + it("does not pass -open when dashboardUrl is not configured", async () => { + const notifier = create(); + await notifier.notify(makeEvent()); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("-open"); + }); + + it("passes -sound default for urgent notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-sound"); + expect(args[args.indexOf("-sound") + 1]).toBe("default"); + }); + + it("does not pass -sound for non-urgent notifications", async () => { + const notifier = create(); + await notifier.notify(makeEvent({ priority: "info" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("-sound"); + }); + + it("respects sound=false config", async () => { + const notifier = create({ sound: false }); + await notifier.notify(makeEvent({ priority: "urgent" })); + + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).not.toContain("-sound"); + }); + + it("falls back to osascript when terminal-notifier is not found", async () => { + mockExecFileSync.mockImplementation(() => { + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("osascript"); + }); + + it("does not use terminal-notifier on Linux", async () => { + mockPlatform.mockReturnValue("linux"); + const notifier = create(); + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); + }); + + it("uses terminal-notifier for notifyWithActions too", async () => { + const notifier = create({ dashboardUrl: "http://localhost:3000" }); + const actions: NotifyAction[] = [{ label: "View", url: "https://example.com" }]; + await notifier.notifyWithActions!(makeEvent(), actions); + + expect(mockExecFile.mock.calls[0][0]).toBe("terminal-notifier"); + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args).toContain("-open"); + }); + }); + + describe("AO Notifier.app backend", () => { + beforeEach(() => { + mockExistsSync.mockImplementation((path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier"), + ); + }); + + it("uses AO Notifier.app before terminal-notifier in auto mode", async () => { + mockExecFileSync.mockReturnValue(Buffer.from("/usr/local/bin/terminal-notifier\n")); + const notifier = create({ dashboardUrl: "http://localhost:3000" }); + await notifier.notify(makeEvent({ message: "native app" })); + + expect(mockExecFile.mock.calls[0][0]).toBe( + "/Users/test/Applications/AO Notifier.app/Contents/MacOS/ao-notifier", + ); + expect(mockExecFile.mock.calls[0][1][0]).toBe("--notify-base64"); + }); + + it("passes event metadata and default open URL to AO Notifier.app", async () => { + const notifier = create({ backend: "ao-app", dashboardUrl: "http://localhost:3001" }); + await notifier.notify(makeEvent({ id: "evt-native", sessionId: "s-9" })); + + const encoded = mockExecFile.mock.calls[0][1][1] as string; + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as { + notificationId: string; + threadId: string; + subtitle: string; + defaultOpenUrl: string; + event: { id: string; sessionId: string }; + }; + expect(payload.notificationId).toMatch(/^evt-native\./); + expect(payload.threadId).toBe("ao.notifications"); + expect(payload.subtitle).toBe("my-project · s-9 · Info"); + expect(payload.defaultOpenUrl).toBe("http://localhost:3001/projects/my-project/sessions/s-9"); + expect(payload.event).toMatchObject({ id: "evt-native", sessionId: "s-9" }); + }); + + it("scopes native notification sequence to each notifier instance", async () => { + const first = create({ backend: "ao-app" }); + const second = create({ backend: "ao-app" }); + + await first.notify(makeEvent({ id: "evt-first" })); + await second.notify(makeEvent({ id: "evt-second" })); + + const firstEncoded = mockExecFile.mock.calls[0][1][1] as string; + const secondEncoded = mockExecFile.mock.calls[1][1][1] as string; + const firstPayload = JSON.parse(Buffer.from(firstEncoded, "base64").toString("utf-8")) as { + notificationId: string; + }; + const secondPayload = JSON.parse(Buffer.from(secondEncoded, "base64").toString("utf-8")) as { + notificationId: string; + }; + + expect(firstPayload.notificationId).toMatch(/^evt-first\..*\.1$/); + expect(secondPayload.notificationId).toMatch(/^evt-second\..*\.1$/); + }); + + it("passes URL actions to AO Notifier.app", async () => { + const notifier = create({ backend: "ao-app" }); + const actions: NotifyAction[] = [ + { label: "Open PR", url: "https://github.com/example/pr/1" }, + { label: "Kill", callbackEndpoint: "/api/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const encoded = mockExecFile.mock.calls[0][1][1] as string; + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as { + body: string; + actions: Array<{ label: string; url?: string; callbackEndpoint?: string }>; + }; + expect(payload.actions).toEqual([ + { label: "Open PR", url: "https://github.com/example/pr/1" }, + ]); + expect(payload.body).toContain("Kill"); + expect(payload.body).not.toContain("Open PR"); + }); + + it("passes callback actions to AO Notifier.app when they resolve against dashboardUrl", async () => { + const notifier = create({ backend: "ao-app", dashboardUrl: "http://localhost:3000" }); + const actions: NotifyAction[] = [ + { label: "Kill", callbackEndpoint: "/api/sessions/app-1/kill" }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const encoded = mockExecFile.mock.calls[0][1][1] as string; + const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as { + body: string; + actions: Array<{ label: string; callbackEndpoint?: string }>; + }; + expect(payload.actions).toEqual([ + { label: "Kill", callbackEndpoint: "http://localhost:3000/api/sessions/app-1/kill" }, + ]); + expect(payload.body).not.toContain("Kill"); + }); + + it("fails when backend ao-app is configured but the app is missing", async () => { + mockExistsSync.mockReturnValue(false); + const notifier = create({ backend: "ao-app" }); + + await expect(notifier.notify(makeEvent())).rejects.toThrow("ao setup desktop"); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it("does not use a placeholder AO Notifier.app in auto mode", async () => { + mockExistsSync.mockImplementation( + (path: string) => + path.endsWith("AO Notifier.app/Contents/MacOS/ao-notifier") || + path.endsWith("AO Notifier.app/Contents/Resources/ao-notifier-placeholder"), + ); + const notifier = create(); + + await notifier.notify(makeEvent()); + + expect(mockExecFile.mock.calls[0][0]).toBe("osascript"); + }); + }); }); diff --git a/packages/plugins/notifier-desktop/src/index.ts b/packages/plugins/notifier-desktop/src/index.ts index c29e6bb0c..cf25ac307 100644 --- a/packages/plugins/notifier-desktop/src/index.ts +++ b/packages/plugins/notifier-desktop/src/index.ts @@ -1,12 +1,17 @@ -import { execFile } from "node:child_process"; -import { platform } from "node:os"; +import { execFile, execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; import { escapeAppleScript, + getNotificationDataV3, + isMac, type PluginModule, type Notifier, type OrchestratorEvent, type NotifyAction, type EventPriority, + type NotificationDataV3, } from "@aoagents/ao-core"; function xmlEscape(s: string): string { @@ -49,6 +54,27 @@ export const manifest = { // Re-export for backwards compatibility export { escapeAppleScript } from "@aoagents/ao-core"; +type DesktopBackend = "auto" | "ao-app" | "terminal-notifier" | "osascript"; +const PLACEHOLDER_MARKER_NAME = "ao-notifier-placeholder"; + +interface MacDeliveryOptions { + backend: DesktopBackend; + appPath: string; + useTerminalNotifier: boolean; +} + +interface DesktopNotificationContent { + title: string; + subtitle?: string; + body: string; +} + +interface NativeActionPayload { + label: string; + url?: string; + callbackEndpoint?: string; +} + /** * Map event priority to notification urgency: * - urgent: sound alert @@ -60,52 +86,413 @@ function shouldPlaySound(priority: EventPriority, soundEnabled: boolean): boolea return priority === "urgent"; } +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +function eventTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + function formatTitle(event: OrchestratorEvent): string { - const prefix = event.priority === "urgent" ? "URGENT" : "Agent Orchestrator"; - return `${prefix} [${event.sessionId}]`; + const data = getNotificationDataV3(event.data); + const title = eventTitle(event, data); + if (event.priority === "urgent") return `URGENT: ${title}`; + if (event.priority === "warning") return `Warning: ${title}`; + return title; } -function formatMessage(event: OrchestratorEvent): string { - return event.message; +function formatSubtitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const segments = [event.projectId, event.sessionId]; + const pr = data?.subject.pr; + if (pr) segments.push(`PR #${pr.number}`); + else segments.push(titleCaseStatus(event.priority)); + return truncate(segments.join(" · "), 120); } -function formatActionsMessage(event: OrchestratorEvent, actions: NotifyAction[]): string { - const actionLabels = actions.map((a) => a.label).join(" | "); - return `${event.message}\n\nActions: ${actionLabels}`; +function formatBranch(data: NotificationDataV3 | null): string | undefined { + const pr = data?.subject.pr; + if (pr?.branch && pr.baseBranch) return `${pr.branch} → ${pr.baseBranch}`; + return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch; +} + +function appendLine(lines: string[], value: string | undefined, maxLength = 180): void { + const trimmed = value?.trim(); + if (!trimmed) return; + lines.push(truncate(trimmed, maxLength)); +} + +function formatStatusLine(data: NotificationDataV3): string | undefined { + const segments: string[] = []; + + if (data.ci?.status) { + const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? []; + const failedText = failedChecks.length > 0 ? ` (${failedChecks.slice(0, 3).join(", ")})` : ""; + segments.push(`CI: ${titleCaseStatus(data.ci.status)}${failedText}`); + } + + if (data.review?.decision) { + const threads = + typeof data.review.unresolvedThreads === "number" + ? `, ${data.review.unresolvedThreads} threads` + : ""; + segments.push(`Review: ${titleCaseStatus(data.review.decision)}${threads}`); + } + + if (typeof data.merge?.ready === "boolean") { + segments.push(`Merge: ${data.merge.ready ? "Ready" : "Not ready"}`); + } + + if (typeof data.merge?.conflicts === "boolean") { + segments.push(`Conflicts: ${data.merge.conflicts ? "Found" : "None"}`); + } + + return segments.length > 0 ? segments.join(" · ") : undefined; +} + +function formatSubjectLine(data: NotificationDataV3 | null): string | undefined { + if (!data) return undefined; + const pr = data.subject.pr; + const issue = data.subject.issue; + const segments: string[] = []; + + if (pr) segments.push(`PR #${pr.number}`); + if (issue) segments.push(issue.id); + + return segments.length > 0 ? segments.join(" · ") : undefined; +} + +function formatDetailLine(label: string, value: string | undefined): string | undefined { + return value ? `${label}: ${value}` : undefined; +} + +function formatActionLine( + actions: NotifyAction[] | undefined, + hiddenActionIndexes: Set = new Set(), +): string | undefined { + const visibleActions = (actions ?? []).filter((_, index) => !hiddenActionIndexes.has(index)); + if (visibleActions.length === 0) return undefined; + return `Actions: ${visibleActions.map((action) => action.label).join(" · ")}`; +} + +function formatBody( + event: OrchestratorEvent, + actions?: NotifyAction[], + options: { hiddenActionIndexes?: Set } = {}, +): string { + const data = getNotificationDataV3(event.data); + const lines: string[] = []; + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const branch = formatBranch(data); + + if (subtitle && subtitle !== event.message) appendLine(lines, subtitle, 150); + appendLine(lines, event.message, 180); + appendLine(lines, formatDetailLine("Context", formatSubjectLine(data)), 160); + if (data) appendLine(lines, formatDetailLine("Status", formatStatusLine(data)), 180); + appendLine(lines, formatDetailLine("Branch", branch), 140); + if (data?.transition) + appendLine(lines, `Transition: ${data.transition.from} → ${data.transition.to}`, 120); + appendLine(lines, formatActionLine(actions, options.hiddenActionIndexes), 160); + + return lines.join("\n"); +} + +function formatContent( + event: OrchestratorEvent, + actions?: NotifyAction[], + options: { hiddenActionIndexes?: Set } = {}, +): DesktopNotificationContent { + const data = getNotificationDataV3(event.data); + return { + title: formatTitle(event), + subtitle: formatSubtitle(event, data), + body: formatBody(event, actions, options), + }; +} + +function defaultMacAppPath(): string { + return join(homedir(), "Applications", "AO Notifier.app"); +} + +function macAppExecutable(appPath: string): string { + return join(appPath, "Contents", "MacOS", "ao-notifier"); +} + +function macAppPlaceholderMarker(appPath: string): string { + return join(appPath, "Contents", "Resources", PLACEHOLDER_MARKER_NAME); +} + +function nativeNotificationId(event: OrchestratorEvent, sequence: number): string { + return `${event.id}.${Date.now()}.${process.pid}.${sequence}`; +} + +function nativeThreadId(): string { + return "ao.notifications"; +} + +function detectAoNotifierApp(appPath: string): boolean { + return existsSync(macAppExecutable(appPath)) && !existsSync(macAppPlaceholderMarker(appPath)); +} + +function parseBackend(value: unknown): DesktopBackend { + if ( + value === "auto" || + value === "ao-app" || + value === "terminal-notifier" || + value === "osascript" + ) { + return value; + } + return "auto"; +} + +function dashboardSessionUrl( + dashboardUrl: string | undefined, + event: OrchestratorEvent, +): string | undefined { + if (!dashboardUrl) return undefined; + try { + const base = new URL(dashboardUrl); + base.pathname = `/projects/${encodeURIComponent(event.projectId)}/sessions/${encodeURIComponent(event.sessionId)}`; + base.search = ""; + base.hash = ""; + return base.toString(); + } catch { + return dashboardUrl; + } +} + +function firstUrlAction(actions: NotifyAction[] | undefined): string | undefined { + return actions?.find((action) => typeof action.url === "string")?.url; +} + +function isAbsoluteHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +function resolveCallbackEndpoint( + callbackEndpoint: string | undefined, + dashboardUrl: string | undefined, +): string | undefined { + if (isAbsoluteHttpUrl(callbackEndpoint)) return callbackEndpoint; + if (!callbackEndpoint || !dashboardUrl) return undefined; + try { + const resolved = new URL(callbackEndpoint, dashboardUrl); + return resolved.protocol === "http:" || resolved.protocol === "https:" + ? resolved.toString() + : undefined; + } catch { + return undefined; + } +} + +function nativeActionPayload( + action: NotifyAction, + dashboardUrl: string | undefined, +): NativeActionPayload | undefined { + if (typeof action.url === "string") { + return { label: action.label, url: action.url }; + } + const callbackEndpoint = resolveCallbackEndpoint(action.callbackEndpoint, dashboardUrl); + return callbackEndpoint ? { label: action.label, callbackEndpoint } : undefined; +} + +function nativeActionIndexes( + actions: NotifyAction[] | undefined, + dashboardUrl: string | undefined, +): Set { + const indexes = new Set(); + for (const [index, action] of (actions ?? []).entries()) { + if (nativeActionPayload(action, dashboardUrl)) indexes.add(index); + } + return indexes; +} + +function nativeActionPayloads( + actions: NotifyAction[] | undefined, + dashboardUrl: string | undefined, +): NativeActionPayload[] { + return (actions ?? []) + .map((action) => nativeActionPayload(action, dashboardUrl)) + .filter((action): action is NativeActionPayload => Boolean(action)) + .slice(0, 4); +} + +function firstActionTarget( + actions: NotifyAction[] | undefined, + dashboardUrl: string | undefined, +): string | undefined { + for (const action of actions ?? []) { + const target = action.url ?? resolveCallbackEndpoint(action.callbackEndpoint, dashboardUrl); + if (target) return target; + } + return undefined; +} + +function primaryOpenUrl( + event: OrchestratorEvent, + dashboardUrl: string | undefined, + actions?: NotifyAction[], +): string | undefined { + return ( + dashboardSessionUrl(dashboardUrl, event) ?? + getNotificationDataV3(event.data)?.subject.pr?.url ?? + firstUrlAction(actions) ?? + firstActionTarget(actions, dashboardUrl) + ); +} + +/** Check once at create() time whether terminal-notifier is available. */ +function detectTerminalNotifier(): boolean { + try { + execFileSync("terminal-notifier", ["--version"], { stdio: "ignore", windowsHide: true }); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } } /** - * Send a desktop notification using osascript (macOS) or notify-send (Linux). - * Falls back gracefully if neither is available. + * Send a desktop notification using terminal-notifier / osascript (macOS) or + * notify-send (Linux). Falls back gracefully if neither is available. * - * Note: Desktop notifications do not support click-through URLs natively. - * On macOS, osascript's `display notification` lacks URL support. - * Consider `terminal-notifier` for click-to-open if needed in the future. + * On macOS, when `terminal-notifier` is installed, notifications support + * click-to-open: clicking the banner opens `openUrl` in the default browser. + * Without it, the osascript fallback is used (no click-through). */ function sendNotification( - title: string, - message: string, - options: { sound: boolean; isUrgent: boolean }, + content: DesktopNotificationContent, + event: OrchestratorEvent, + options: { + sound: boolean; + isUrgent: boolean; + mac: MacDeliveryOptions; + notificationId: string; + openUrl?: string; + actions?: NativeActionPayload[]; + fallbackContent?: DesktopNotificationContent; + }, ): Promise { return new Promise((resolve, reject) => { const os = platform(); if (os === "darwin") { - const safeTitle = escapeAppleScript(title); - const safeMessage = escapeAppleScript(message); - const soundClause = options.sound ? ' sound name "default"' : ""; - const script = `display notification "${safeMessage}" with title "${safeTitle}"${soundClause}`; - execFile("osascript", ["-e", script], (err) => { - if (err) reject(err); - else resolve(); - }); + const backend = + options.mac.backend === "auto" + ? detectAoNotifierApp(options.mac.appPath) + ? "ao-app" + : options.mac.useTerminalNotifier + ? "terminal-notifier" + : "osascript" + : options.mac.backend; + + if (backend === "ao-app") { + if (!detectAoNotifierApp(options.mac.appPath)) { + reject(new Error("AO Notifier.app is not installed. Run: ao setup desktop")); + return; + } + + const payload = { + notificationId: options.notificationId, + threadId: nativeThreadId(), + title: content.title, + subtitle: content.subtitle, + body: content.body, + sound: options.sound, + defaultOpenUrl: options.openUrl, + event: { + id: event.id, + type: event.type, + priority: event.priority, + sessionId: event.sessionId, + projectId: event.projectId, + timestamp: event.timestamp.toISOString(), + }, + actions: (options.actions ?? []).map((action) => ({ + label: action.label, + url: action.url, + callbackEndpoint: action.callbackEndpoint, + })), + }; + const encoded = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64"); + execFile(macAppExecutable(options.mac.appPath), ["--notify-base64", encoded], (err) => { + if (err) reject(err); + else resolve(); + }); + } else if (backend === "terminal-notifier") { + const fallbackContent = options.fallbackContent ?? content; + const args = ["-title", fallbackContent.title, "-message", fallbackContent.body]; + if (fallbackContent.subtitle) { + args.push("-subtitle", fallbackContent.subtitle); + } + if (options.openUrl) { + args.push("-open", options.openUrl); + } + if (options.sound) { + args.push("-sound", "default"); + } + execFile("terminal-notifier", args, (err) => { + if (err) reject(err); + else resolve(); + }); + } else { + const fallbackContent = options.fallbackContent ?? content; + const safeTitle = escapeAppleScript(fallbackContent.title); + const safeSubtitle = fallbackContent.subtitle + ? ` subtitle "${escapeAppleScript(fallbackContent.subtitle)}"` + : ""; + const safeMessage = escapeAppleScript(fallbackContent.body); + const soundClause = options.sound ? ' sound name "default"' : ""; + const script = `display notification "${safeMessage}" with title "${safeTitle}"${safeSubtitle}${soundClause}`; + execFile("osascript", ["-e", script], (err) => { + if (err) reject(err); + else resolve(); + }); + } } else if (os === "linux") { // Linux urgency is driven by event priority, not the macOS sound config const args: string[] = []; if (options.isUrgent) { args.push("--urgency=critical"); } - args.push(title, message); + const fallbackContent = options.fallbackContent ?? content; + args.push(fallbackContent.title, fallbackContent.body); execFile("notify-send", args, (err) => { if (err) reject(err); else resolve(); @@ -114,7 +501,12 @@ function sendNotification( // WinRT toast via PowerShell — no third-party deps. Encode the script // as UTF-16LE base64 so we never fight with PowerShell's argument // tokenizer over quotes, special chars, or newlines in the toast XML. - const script = buildWindowsToastScript(title, message, options.sound); + const fallbackContent = options.fallbackContent ?? content; + const script = buildWindowsToastScript( + fallbackContent.subtitle ?? fallbackContent.title, + fallbackContent.body, + options.sound, + ); const encoded = Buffer.from(script, "utf16le").toString("base64"); execFile( "powershell.exe", @@ -140,27 +532,61 @@ function sendNotification( } export function create(config?: Record): Notifier { + let nativeNotificationSequence = 0; const soundEnabled = typeof config?.sound === "boolean" ? config.sound : true; + const dashboardUrl = typeof config?.dashboardUrl === "string" ? config.dashboardUrl : undefined; + const backend = parseBackend(config?.backend); + const appPath = typeof config?.appPath === "string" ? config.appPath : defaultMacAppPath(); + const hasTerminalNotifier = + isMac() && (backend === "auto" || backend === "terminal-notifier") + ? detectTerminalNotifier() + : false; + const mac = { + backend, + appPath, + useTerminalNotifier: hasTerminalNotifier, + }; + const nextNativeNotificationId = (event: OrchestratorEvent): string => { + nativeNotificationSequence += 1; + return nativeNotificationId(event, nativeNotificationSequence); + }; return { name: "desktop", async notify(event: OrchestratorEvent): Promise { - const title = formatTitle(event); - const message = formatMessage(event); + const content = formatContent(event); const sound = shouldPlaySound(event.priority, soundEnabled); const isUrgent = event.priority === "urgent"; - await sendNotification(title, message, { sound, isUrgent }); + await sendNotification(content, event, { + sound, + isUrgent, + mac, + notificationId: nextNativeNotificationId(event), + openUrl: primaryOpenUrl(event, dashboardUrl), + }); }, async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { - // Desktop notifications cannot display interactive action buttons. - // Actions are rendered as text labels in the notification body as a fallback. - const title = formatTitle(event); - const message = formatActionsMessage(event, actions); + const nativeActions = nativeActionPayloads(actions, dashboardUrl); + const content = formatContent(event, actions, { + hiddenActionIndexes: + backend === "ao-app" || (backend === "auto" && detectAoNotifierApp(appPath)) + ? nativeActionIndexes(actions, dashboardUrl) + : undefined, + }); + const fallbackContent = formatContent(event, actions); const sound = shouldPlaySound(event.priority, soundEnabled); const isUrgent = event.priority === "urgent"; - await sendNotification(title, message, { sound, isUrgent }); + await sendNotification(content, event, { + sound, + isUrgent, + mac, + notificationId: nextNativeNotificationId(event), + openUrl: primaryOpenUrl(event, dashboardUrl, actions), + actions: nativeActions, + fallbackContent, + }); }, }; } diff --git a/packages/plugins/notifier-discord/src/index.test.ts b/packages/plugins/notifier-discord/src/index.test.ts index 1151c3976..925ec2604 100644 --- a/packages/plugins/notifier-discord/src/index.test.ts +++ b/packages/plugins/notifier-discord/src/index.test.ts @@ -16,6 +16,14 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "ao-5", projectId: "ao" } }, + ...overrides, + }; +} + describe("notifier-discord", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -50,11 +58,11 @@ describe("notifier-discord", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.username).toBe("Agent Orchestrator"); + expect(body.allowed_mentions).toEqual({ parse: [] }); expect(body.embeds).toHaveLength(1); const embed = body.embeds[0]; - expect(embed.title).toContain("ao-5"); - expect(embed.title).toContain("reaction.escalated"); + expect(embed.title).toContain("Reaction Escalated"); expect(embed.description).toBe("CI failed after 5 retries"); expect(embed.color).toBe(0xed4245); // red for urgent expect(embed.timestamp).toBe("2026-03-20T12:00:00.000Z"); @@ -71,7 +79,8 @@ describe("notifier-discord", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); const fields = body.embeds[0].fields; expect(fields).toContainEqual(expect.objectContaining({ name: "Project", value: "ao" })); - expect(fields).toContainEqual(expect.objectContaining({ name: "Priority", value: "urgent" })); + expect(fields).toContainEqual(expect.objectContaining({ name: "Session", value: "ao-5" })); + expect(fields).toContainEqual(expect.objectContaining({ name: "Priority", value: "Urgent" })); }); it("includes PR link when available", async () => { @@ -79,7 +88,16 @@ describe("notifier-discord", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); - await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } })); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "ao-5", projectId: "ao" }, + pr: { number: 42, url: "https://github.com/org/repo/pull/42" }, + }, + }), + }), + ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); const prField = body.embeds[0].fields.find((f: { name: string }) => f.name === "Pull Request"); @@ -91,11 +109,41 @@ describe("notifier-discord", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); - await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); + await notifier.notify(makeEvent({ data: makeV3Data({ ci: { status: "passing" } }) })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); const ciField = body.embeds[0].fields.find((f: { name: string }) => f.name === "CI"); - expect(ciField.value).toContain("passing"); + expect(ciField.value).toContain("Passing"); + }); + + it("encodes closing parentheses in markdown link URLs", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + ci: { + status: "failing", + failedChecks: [ + { + name: "build(test)", + status: "completed", + conclusion: "failure", + url: "https://github.com/org/repo/actions/runs/1?q=(abc)", + }, + ], + }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const checksField = body.embeds[0].fields.find((f: { name: string }) => f.name === "Checks"); + expect(checksField.value).toContain( + "[buildtest: completed/failure](https://github.com/org/repo/actions/runs/1?q=(abc%29)", + ); }); it("notifyWithActions includes action links", async () => { @@ -124,6 +172,7 @@ describe("notifier-discord", () => { const body = JSON.parse(fetchMock.mock.calls[0][1].body); expect(body.content).toBe("Session ao-5 completed successfully"); + expect(body.allowed_mentions).toEqual({ parse: [] }); expect(body.embeds).toBeUndefined(); }); @@ -131,7 +180,10 @@ describe("notifier-discord", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); vi.stubGlobal("fetch", fetchMock); - const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc", username: "AO Bot" }); + const notifier = create({ + webhookUrl: "https://discord.com/api/webhooks/123/abc", + username: "AO Bot", + }); await notifier.notify(makeEvent()); const body = JSON.parse(fetchMock.mock.calls[0][1].body); @@ -187,13 +239,57 @@ describe("notifier-discord", () => { await notifier.notify(makeEvent({ priority: "info" })); let body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.embeds[0].color).toBe(0x57f287); // green + expect(body.embeds[0].color).toBe(0x3498db); // blue await notifier.notify(makeEvent({ priority: "warning" })); body = JSON.parse(fetchMock.mock.calls[1][1].body); expect(body.embeds[0].color).toBe(0xfee75c); // yellow }); + it("uses success color and professional fields for merge-ready events", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://discord.com/api/webhooks/123/abc" }); + await notifier.notify( + makeEvent({ + type: "merge.ready", + priority: "action", + message: "PR #1579 is ready to merge", + data: makeV3Data({ + subject: { + session: { id: "ao-5", projectId: "ao" }, + pr: { + number: 1579, + title: "Normalize AO notifier payloads", + url: "https://github.com/org/repo/pull/1579", + branch: "feat/notifiers", + baseBranch: "main", + }, + }, + ci: { status: "passing" }, + review: { decision: "approved" }, + merge: { ready: true, conflicts: false, isBehind: false }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const embed = body.embeds[0]; + expect(embed.title).toContain("PR #1579 ready to merge"); + expect(embed.color).toBe(0x57f287); + expect(embed.url).toBe("https://github.com/org/repo/pull/1579"); + expect(embed.description).toContain("Normalize AO notifier payloads"); + expect(embed.fields).toContainEqual(expect.objectContaining({ name: "CI" })); + expect(embed.fields).toContainEqual( + expect.objectContaining({ name: "Review", value: "Approved" }), + ); + expect(embed.fields).toContainEqual(expect.objectContaining({ name: "Merge", value: "Ready" })); + expect(embed.fields).toContainEqual( + expect.objectContaining({ name: "Sync", value: "Up to date" }), + ); + }); + it("handles 204 No Content as success", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }); vi.stubGlobal("fetch", fetchMock); diff --git a/packages/plugins/notifier-discord/src/index.ts b/packages/plugins/notifier-discord/src/index.ts index 00191239e..564815fb1 100644 --- a/packages/plugins/notifier-discord/src/index.ts +++ b/packages/plugins/notifier-discord/src/index.ts @@ -1,4 +1,5 @@ import { + getNotificationDataV3, recordActivityEvent, validateUrl, type PluginModule, @@ -7,6 +8,8 @@ import { type NotifyAction, type NotifyContext, type EventPriority, + type NotificationDataV3, + type NotificationCICheck, CI_STATUS, } from "@aoagents/ao-core"; import { isRetryableHttpStatus, normalizeRetryConfig } from "@aoagents/ao-core/utils"; @@ -18,75 +21,309 @@ export const manifest = { version: "0.1.0", }; -// Discord embed color codes (decimal) -const PRIORITY_COLOR: Record = { - urgent: 0xed4245, // red - action: 0x5865f2, // blurple - warning: 0xfee75c, // yellow - info: 0x57f287, // green -}; - -const PRIORITY_EMOJI: Record = { - urgent: "\u{1F6A8}", // rotating light - action: "\u{1F449}", // point right - warning: "\u{26A0}\u{FE0F}", // warning - info: "\u{2139}\u{FE0F}", // info -}; - -const DISCORD_WEBHOOK_URL_RE = - /^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//; +const DISCORD_WEBHOOK_URL_RE = /^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//; +const DISCORD_EMBED_TITLE_MAX = 256; const EMBED_DESCRIPTION_MAX = 4096; +const DISCORD_FIELD_NAME_MAX = 256; +const DISCORD_FIELD_VALUE_MAX = 1024; +const DISCORD_MAX_FIELDS = 25; interface DiscordEmbed { title: string; description: string; color: number; + url?: string; fields?: { name: string; value: string; inline?: boolean }[]; timestamp?: string; footer?: { text: string }; } +interface DiscordTone { + emoji: string; + label: string; + color: number; +} + +const SUCCESS_TONE: DiscordTone = { + emoji: "\u{2705}", + label: "Complete", + color: 0x57f287, +}; + +const PRIORITY_TONE: Record = { + urgent: { + emoji: "\u{1F6A8}", + label: "Urgent", + color: 0xed4245, + }, + action: { + emoji: "\u{1F449}", + label: "Action required", + color: 0x5865f2, + }, + warning: { + emoji: "\u{26A0}\u{FE0F}", + label: "Warning", + color: 0xfee75c, + }, + info: { + emoji: "\u{2139}\u{FE0F}", + label: "Information", + color: 0x3498db, + }, +}; + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}\u2026` : value; +} + +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function toneForEvent(event: OrchestratorEvent): DiscordTone { + if (event.type === "merge.ready") return { ...SUCCESS_TONE, label: "Ready to merge" }; + if (event.type === "summary.all_complete") return { ...SUCCESS_TONE, label: "All complete" }; + if (event.type === "ci.failing" || event.type === "session.stuck") return PRIORITY_TONE.urgent; + if (event.type === "review.changes_requested") return PRIORITY_TONE.warning; + return PRIORITY_TONE[event.priority] ?? PRIORITY_TONE.info; +} + +function eventTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function fieldValue(value: string | number | boolean | undefined | null): string { + if (value === undefined || value === null || value === "") return "Not available"; + return truncate(String(value), DISCORD_FIELD_VALUE_MAX); +} + +function appendField( + fields: NonNullable, + name: string, + value: string | number | boolean | undefined | null, + inline = true, +): void { + if (value === undefined || value === null || value === "") return; + if (fields.length >= DISCORD_MAX_FIELDS) return; + fields.push({ + name: truncate(name, DISCORD_FIELD_NAME_MAX), + value: fieldValue(value), + inline, + }); +} + +function formatMarkdownLink(label: string, url: string): string { + return `[${label.replace(/[\][()]/g, "")}](${url.replace(/\)/g, "%29")})`; +} + +function formatBranch(data: NotificationDataV3 | null): string | undefined { + const pr = data?.subject.pr; + if (pr?.branch && pr.baseBranch) return `${pr.branch} -> ${pr.baseBranch}`; + return pr?.branch ?? pr?.baseBranch ?? data?.subject.branch; +} + +function formatCheck(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const label = `${check.name}: ${status}`; + return check.url ? formatMarkdownLink(label, check.url) : label; +} + +function isAbsoluteHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function appendDataFields( + fields: NonNullable, + data: NotificationDataV3 | null, +): void { + if (!data) return; + + const pr = data.subject.pr; + const issue = data.subject.issue; + const branch = formatBranch(data); + + appendField( + fields, + "Pull Request", + pr + ? `${formatMarkdownLink(`#${pr.number}`, pr.url)}${pr.title ? ` - ${pr.title}` : ""}` + : undefined, + false, + ); + appendField(fields, "Branch", branch); + appendField( + fields, + "Issue", + issue ? `${issue.id}${issue.title ? ` - ${issue.title}` : ""}` : undefined, + ); + appendField(fields, "CI", data.ci?.status ? formatCiStatus(data) : undefined); + appendField( + fields, + "Review", + data.review?.decision ? titleCaseStatus(data.review.decision) : undefined, + ); + appendField(fields, "Review Threads", data.review?.unresolvedThreads); + appendField( + fields, + "Merge", + typeof data.merge?.ready === "boolean" ? (data.merge.ready ? "Ready" : "Not ready") : undefined, + ); + appendField( + fields, + "Conflicts", + typeof data.merge?.conflicts === "boolean" + ? data.merge.conflicts + ? "Found" + : "None" + : undefined, + ); + appendField( + fields, + "Sync", + typeof data.merge?.isBehind === "boolean" + ? data.merge.isBehind + ? "Behind base" + : "Up to date" + : undefined, + ); + appendField( + fields, + "Transition", + data.transition ? `${data.transition.from} -> ${data.transition.to}` : undefined, + ); + appendField( + fields, + "Reaction", + data.reaction ? `${data.reaction.key} -> ${data.reaction.action}` : undefined, + ); + appendField( + fields, + "Escalation", + data.escalation ? `${data.escalation.attempts} attempts (${data.escalation.cause})` : undefined, + ); + + const checks = (data.ci?.failedChecks ?? []).slice(0, 8).map(formatCheck); + appendField(fields, "Checks", checks.length > 0 ? checks.join("\n") : undefined, false); + appendField( + fields, + "Blockers", + data.merge?.blockers?.length ? data.merge.blockers.slice(0, 8).join("\n") : undefined, + false, + ); + + const links = [ + ...(pr?.url ? [formatMarkdownLink("Pull request", pr.url)] : []), + ...(data.review?.url ? [formatMarkdownLink("Review", data.review.url)] : []), + ]; + appendField(fields, "Links", links.length > 0 ? links.join(" | ") : undefined, false); +} + +function formatCiStatus(data: NotificationDataV3): string { + if (!data.ci?.status) return ""; + const ciEmoji = data.ci.status === CI_STATUS.PASSING ? "\u{2705}" : "\u{274C}"; + const failedChecks = data.ci.failedChecks?.map((check) => check.name) ?? []; + const failedCheckText = failedChecks.length > 0 ? `\nFailed: ${failedChecks.join(", ")}` : ""; + return `${ciEmoji} ${titleCaseStatus(data.ci.status)}${failedCheckText}`; +} + +function appendActionField( + fields: NonNullable, + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): void { + const seen = new Set(); + const links: string[] = []; + + const prUrl = data?.subject.pr?.url; + if (prUrl) { + links.push(formatMarkdownLink("View PR", prUrl)); + seen.add(prUrl); + } + + const reviewUrl = data?.review?.url; + if (reviewUrl && !seen.has(reviewUrl)) { + links.push(formatMarkdownLink("View Review", reviewUrl)); + seen.add(reviewUrl); + } + + for (const action of actions ?? []) { + if (action.url) { + if (seen.has(action.url)) continue; + links.push(formatMarkdownLink(action.label, action.url)); + seen.add(action.url); + continue; + } + if (isAbsoluteHttpUrl(action.callbackEndpoint)) { + links.push(formatMarkdownLink(action.label, action.callbackEndpoint)); + continue; + } + links.push(`\`${action.label}\``); + } + + appendField(fields, "Actions", links.slice(0, 8).join(" | "), false); +} + +function formatDescription(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; + const description = subtitle ? `**${subtitle}**\n${event.message}` : event.message; + return truncate(description, EMBED_DESCRIPTION_MAX); +} + function buildEmbed(event: OrchestratorEvent, actions?: NotifyAction[]): DiscordEmbed { - const emoji = PRIORITY_EMOJI[event.priority]; - const description = - event.message.length > EMBED_DESCRIPTION_MAX - ? event.message.slice(0, EMBED_DESCRIPTION_MAX - 1) + "\u2026" - : event.message; + const data = getNotificationDataV3(event.data); + const tone = toneForEvent(event); + const fields: NonNullable = []; + + appendField(fields, "Project", event.projectId); + appendField(fields, "Session", event.sessionId); + appendField(fields, "Priority", tone.label); + appendDataFields(fields, data); + appendActionField(fields, data, actions); + const embed: DiscordEmbed = { - title: `${emoji} ${event.type} — ${event.sessionId}`, - description, - color: PRIORITY_COLOR[event.priority], - fields: [ - { name: "Project", value: event.projectId, inline: true }, - { name: "Priority", value: event.priority, inline: true }, - ], + title: truncate(`${tone.emoji} ${eventTitle(event, data)}`, DISCORD_EMBED_TITLE_MAX), + description: formatDescription(event, data), + color: tone.color, + ...(data?.subject.pr?.url ? { url: data.subject.pr.url } : {}), + fields, timestamp: event.timestamp.toISOString(), footer: { text: "Agent Orchestrator" }, }; - // Add PR link if available - const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; - if (prUrl) { - embed.fields!.push({ name: "Pull Request", value: `[View PR](${prUrl})`, inline: false }); - } - - // Add CI status if available - const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; - if (ciStatus) { - const ciEmoji = ciStatus === CI_STATUS.PASSING ? "\u{2705}" : "\u{274C}"; - embed.fields!.push({ name: "CI", value: `${ciEmoji} ${ciStatus}`, inline: true }); - } - - // Add actions as a field - if (actions && actions.length > 0) { - const actionLinks = actions.map((a) => { - if (a.url) return `[${a.label}](${a.url})`; - return `\`${a.label}\``; - }); - embed.fields!.push({ name: "Actions", value: actionLinks.join(" | "), inline: false }); - } - return embed; } @@ -141,7 +378,9 @@ async function postWithRetry( rateLimitRetries, }, }); - lastError = new Error(`Discord webhook rate-limited (HTTP 429)${body ? `: ${body.trim()}` : ""}`); + lastError = new Error( + `Discord webhook rate-limited (HTTP 429)${body ? `: ${body.trim()}` : ""}`, + ); throw lastError; } @@ -178,26 +417,27 @@ export function create(config?: Record): Notifier { if (!webhookUrl) { console.warn( "[notifier-discord] No webhookUrl configured.\n" + - " Set it in agent-orchestrator.yaml under notifiers.discord.webhookUrl\n" + - " Create a webhook: Discord Server Settings > Integrations > Webhooks > New Webhook", + " Set it in agent-orchestrator.yaml under notifiers.discord.webhookUrl\n" + + " Create a webhook: Discord Server Settings > Integrations > Webhooks > New Webhook", ); } else { validateUrl(webhookUrl, "notifier-discord"); if (!DISCORD_WEBHOOK_URL_RE.test(webhookUrl)) { console.warn( "[notifier-discord] webhookUrl does not match expected Discord webhook format.\n" + - " Expected: https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...", + " Expected: https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...", ); } } // Discord requires thread_id as a URL query param, not in the JSON body - const effectiveUrl = webhookUrl && threadId - ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${encodeURIComponent(threadId)}` - : webhookUrl; + const effectiveUrl = + webhookUrl && threadId + ? `${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}thread_id=${encodeURIComponent(threadId)}` + : webhookUrl; function buildPayload(embeds: DiscordEmbed[]): Record { - const payload: Record = { username, embeds }; + const payload: Record = { username, embeds, allowed_mentions: { parse: [] } }; if (avatarUrl) payload.avatar_url = avatarUrl; return payload; } @@ -219,7 +459,11 @@ export function create(config?: Record): Notifier { async post(message: string, _context?: NotifyContext): Promise { if (!effectiveUrl) return null; - const payload: Record = { username, content: message }; + const payload: Record = { + username, + content: message, + allowed_mentions: { parse: [] }, + }; if (avatarUrl) payload.avatar_url = avatarUrl; // thread_id is already passed as a URL query param via effectiveUrl await postWithRetry(effectiveUrl, payload, retries, retryDelayMs); diff --git a/packages/plugins/notifier-openclaw/README.md b/packages/plugins/notifier-openclaw/README.md index 0ffcb757f..d74504b5e 100644 --- a/packages/plugins/notifier-openclaw/README.md +++ b/packages/plugins/notifier-openclaw/README.md @@ -8,12 +8,25 @@ OpenClaw notifier plugin for AO escalation events. ao setup openclaw ``` -This interactive wizard auto-detects your OpenClaw gateway, validates the connection, and writes the config. For non-interactive use (e.g., in CI/CD pipelines or automation scripts): +This interactive wizard auto-detects your OpenClaw gateway, lets you reuse or change the URL, OpenClaw config path, and routing values, then writes the AO config. For non-interactive use (e.g., in CI/CD pipelines or automation scripts): ```bash -ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --token YOUR_TOKEN --non-interactive +ao setup openclaw --url http://127.0.0.1:18789/hooks/agent --non-interactive ``` +AO does not generate the token or write shell-profile exports. Local setup reads `hooks.token` from your OpenClaw config. For a remote OpenClaw gateway, you can pass `--token` and AO will store that token in `agent-orchestrator.yaml`. + +Useful follow-up commands: + +```bash +ao setup openclaw --refresh +ao setup openclaw --status +``` + +Interactive setup asks which notification priorities OpenClaw should receive. +For scriptable setup, pass `--routing-preset urgent-only`, `urgent-action`, or +`all`. + ## Required OpenClaw config (`openclaw.json`) ```json @@ -34,7 +47,7 @@ notifiers: openclaw: plugin: openclaw url: http://127.0.0.1:18789/hooks/agent - token: ${OPENCLAW_HOOKS_TOKEN} + openclawConfigPath: ~/.openclaw/openclaw.json ``` ## Behavior @@ -46,8 +59,8 @@ notifiers: ## Token rotation 1. Rotate `hooks.token` in OpenClaw. -2. Update `OPENCLAW_HOOKS_TOKEN` used by AO. -3. Verify old token returns `401` and new token returns `200`. +2. Restart OpenClaw so it picks up the new config. +3. Run `ao setup openclaw --status` to verify the new token. ## Known limitation (Phase 0) diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts index 1e690e6e5..6cfc03597 100644 --- a/packages/plugins/notifier-openclaw/src/index.test.ts +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import type { NotifyAction, OrchestratorEvent } from "@aoagents/ao-core"; +import { + buildCIFailureNotificationData, + buildSessionTransitionNotificationData, + type NotificationEventContext, + type NotifyAction, + type OrchestratorEvent, +} from "@aoagents/ao-core"; import { create, manifest } from "./index.js"; function makeEvent(overrides: Partial = {}): OrchestratorEvent { @@ -19,6 +25,23 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } +const prContext: NotificationEventContext = { + pr: { + number: 1579, + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579", + title: "Normalize AO notifier payloads", + branch: "ao/demo-notifier-harness", + baseBranch: "main", + owner: "ComposioHQ", + repo: "agent-orchestrator", + isDraft: false, + }, + issueId: "AO-1579", + issueTitle: "Make AO notification payloads API-grade", + summary: "Normalize AO notifier payloads", + branch: "ao/demo-notifier-harness", +}; + describe("notifier-openclaw", () => { let tempConfigDir: string; let tempConfigPath: string; @@ -57,23 +80,38 @@ describe("notifier-openclaw", () => { it("uses token from OPENCLAW_HOOKS_TOKEN env", async () => { process.env.OPENCLAW_HOOKS_TOKEN = "env-token"; + const missingOpenClawConfigPath = join(tempConfigDir, "missing-openclaw.json"); const fetchMock = vi.fn().mockResolvedValue({ ok: true }); vi.stubGlobal("fetch", fetchMock); - const notifier = create(); + const notifier = create({ openclawConfigPath: missingOpenClawConfigPath }); await notifier.notify(makeEvent()); const headers = fetchMock.mock.calls[0][1].headers; expect(headers["Authorization"]).toBe("Bearer env-token"); }); + it("uses hooks token from configured OpenClaw config path", async () => { + const openclawConfigPath = join(tempConfigDir, "openclaw.json"); + writeFileSync(openclawConfigPath, JSON.stringify({ hooks: { token: "config-token" } })); + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ openclawConfigPath }); + await notifier.notify(makeEvent()); + + const headers = fetchMock.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer config-token"); + }); + it("warns and sends without Authorization when token missing", async () => { + const missingOpenClawConfigPath = join(tempConfigDir, "missing-openclaw.json"); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const fetchMock = vi.fn().mockResolvedValue({ ok: true }); vi.stubGlobal("fetch", fetchMock); - const notifier = create(); + const notifier = create({ openclawConfigPath: missingOpenClawConfigPath }); await notifier.notify(makeEvent()); const headers = fetchMock.mock.calls[0][1].headers as Record; @@ -103,16 +141,123 @@ describe("notifier-openclaw", () => { expect(body.sessionKey).toBe("hook:ao:ao-12-x"); }); - it("notifyWithActions appends action labels", async () => { + it("notifyWithActions appends action links", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }); vi.stubGlobal("fetch", fetchMock); const notifier = create({ token: "tok" }); - const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }]; + const actions: NotifyAction[] = [ + { label: "Open dashboard", url: "http://localhost:3000" }, + { label: "Acknowledge", callbackEndpoint: "http://localhost:3000/api/ack" }, + ]; await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.message).toContain("Actions available: retry, kill"); + expect(body.message).toContain("**Actions**"); + expect(body.message).toContain("- [Open dashboard](http://localhost:3000)"); + expect(body.message).toContain("- [Acknowledge](http://localhost:3000/api/ack)"); + }); + + it("escapes markdown-sensitive labels and URLs in links", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + const actions: NotifyAction[] = [ + { + label: "Open [prod] (now) *please*", + url: "https://github.com/org/repo/pull/1?a=(test)", + }, + ]; + await notifier.notifyWithActions!(makeEvent(), actions); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain( + "- [Open \\[prod\\] \\(now\\) \\*please\\*](https://github.com/org/repo/pull/1?a=%28test%29)", + ); + }); + + it("formats v3 CI notifications as a compact OpenClaw brief", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify( + makeEvent({ + type: "ci.failing", + priority: "action", + sessionId: "demo-agent-19", + projectId: "demo", + message: "CI is failing on PR #1579", + data: buildCIFailureNotificationData({ + sessionId: "demo-agent-19", + projectId: "demo", + context: prContext, + failedChecks: [ + { + name: "typecheck", + status: "failed", + conclusion: "FAILURE", + url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks", + }, + ], + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("**AO ACTION** `ci.failing`"); + expect(body.message).toContain("**Pull Request**"); + expect(body.message).toContain( + "[#1579 - Normalize AO notifier payloads](https://github.com/ComposioHQ/agent-orchestrator/pull/1579)", + ); + expect(body.message).toContain("**Checks**"); + expect(body.message).toContain( + "- [typecheck](https://github.com/ComposioHQ/agent-orchestrator/pull/1579/checks): `failed/FAILURE`", + ); + expect(body.message).not.toContain("Context: {"); + }); + + it("formats v3 merge notifications with status and links", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ token: "tok" }); + await notifier.notify( + makeEvent({ + type: "merge.ready", + priority: "action", + sessionId: "demo-agent-29", + projectId: "demo", + message: "PR #1579 is ready to merge", + data: buildSessionTransitionNotificationData({ + eventType: "merge.ready", + sessionId: "demo-agent-29", + projectId: "demo", + context: prContext, + oldStatus: "approved", + newStatus: "mergeable", + enrichment: { + state: "open", + ciStatus: "passing", + reviewDecision: "approved", + mergeable: true, + title: "Normalize AO notifier payloads", + hasConflicts: false, + isBehind: false, + }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.message).toContain("- Transition: `approved` -> `mergeable`"); + expect(body.message).toContain("- CI: `passing`"); + expect(body.message).toContain("- Review: `approved`"); + expect(body.message).toContain("- Merge ready: yes"); + expect(body.message).toContain( + "- [Pull request](https://github.com/ComposioHQ/agent-orchestrator/pull/1579)", + ); }); it("post uses context sessionId when provided", async () => { @@ -190,9 +335,7 @@ describe("notifier-openclaw", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ token: "tok", retries: 0 }); - await expect(notifier.notify(makeEvent())).rejects.toThrow( - "Can't reach OpenClaw gateway", - ); + await expect(notifier.notify(makeEvent())).rejects.toThrow("Can't reach OpenClaw gateway"); }); it("records success telemetry when a notification is sent", async () => { diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index ced31e7cf..16ffd4b3c 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -2,7 +2,10 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from " import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { + getNotificationDataV3, type EventPriority, + type NotificationCICheck, + type NotificationDataV3, type Notifier, type NotifyAction, type NotifyContext, @@ -14,16 +17,22 @@ import { import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@aoagents/ao-core/utils"; /** - * Read the hooks token from ~/.openclaw/openclaw.json as a fallback for - * daemon contexts where the shell profile (and OPENCLAW_HOOKS_TOKEN) isn't - * sourced. This file is written by `ao setup openclaw` and lives outside - * the project directory so it's never committed to version control. + * Read the hooks token from OpenClaw's config. AO treats OpenClaw as the + * owner of hooks.token; setup only points the notifier at this file. */ -function readTokenFromOpenClawConfig(): string | undefined { +function expandHomePath(path: string): string { + if (path === "~") return homedir(); + if (path.startsWith("~/")) return join(homedir(), path.slice(2)); + return path; +} + +function readTokenFromOpenClawConfig(configPath?: string): string | undefined { try { - const configPath = join(homedir(), ".openclaw", "openclaw.json"); - if (!existsSync(configPath)) return undefined; - const raw = readFileSync(configPath, "utf-8"); + const resolvedPath = expandHomePath( + configPath ?? join(homedir(), ".openclaw", "openclaw.json"), + ); + if (!existsSync(resolvedPath)) return undefined; + const raw = readFileSync(resolvedPath, "utf-8"); const config = JSON.parse(raw) as Record; const token = (config.hooks as Record | undefined)?.token; return typeof token === "string" && token ? token : undefined; @@ -247,24 +256,170 @@ function eventHeadline(event: OrchestratorEvent): string { warning: "WARNING", info: "INFO", }; - return `[AO ${priorityTag[event.priority]}] ${event.sessionId} ${event.type}`; + return `**AO ${priorityTag[event.priority]}** \`${event.type}\``; } -function stringifyData(data: Record): string { - const entries = Object.entries(data); - if (entries.length === 0) return ""; - return `Context: ${JSON.stringify(data)}`; +function isPrimitive(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function compactValue(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if (isPrimitive(value)) return String(value); + if (value instanceof Date) return value.toISOString(); + return undefined; +} + +function truncate(value: string, maxLength = 140): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; +} + +function escapeMarkdownLinkLabel(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .replace(/\*/g, "\\*") + .replace(/_/g, "\\_") + .replace(/~/g, "\\~") + .replace(/`/g, "\\`"); +} + +function escapeMarkdownLinkUrl(value: string): string { + return value.replace(/[()\\\s<>]/g, (char) => { + const code = char.codePointAt(0) ?? 0; + return `%${code.toString(16).toUpperCase().padStart(2, "0")}`; + }); +} + +function formatLink(label: string, url: string): string { + return `[${escapeMarkdownLinkLabel(label)}](${escapeMarkdownLinkUrl(url)})`; +} + +function pushSection(lines: string[], title: string, items: string[]): void { + const filtered = items.filter(Boolean); + if (filtered.length === 0) return; + lines.push("", `**${title}**`, ...filtered); +} + +function formatSubjectLines(data: NotificationDataV3): string[] { + const subject = data.subject; + const lines = [ + `- Project: \`${subject.session.projectId}\``, + `- Session: \`${subject.session.id}\``, + ]; + + if (subject.issue) { + const label = subject.issue.title + ? `${subject.issue.id} - ${subject.issue.title}` + : subject.issue.id; + lines.push(`- Issue: ${label}`); + } + + return lines; +} + +function formatPrLines(data: NotificationDataV3): string[] { + const pr = data.subject.pr; + if (!pr) return []; + + const title = pr.title ? ` - ${pr.title}` : ""; + const lines = [`- PR: ${formatLink(`#${pr.number}${title}`, pr.url)}`]; + if (pr.branch) lines.push(`- Branch: \`${pr.branch}\``); + if (pr.baseBranch) lines.push(`- Base: \`${pr.baseBranch}\``); + if (typeof pr.isDraft === "boolean") lines.push(`- Draft: ${pr.isDraft ? "yes" : "no"}`); + return lines; +} + +function formatStatusLines(data: NotificationDataV3): string[] { + const lines: string[] = []; + if (data.transition) { + lines.push(`- Transition: \`${data.transition.from}\` -> \`${data.transition.to}\``); + } + if (data.ci?.status) lines.push(`- CI: \`${data.ci.status}\``); + if (data.review?.decision) lines.push(`- Review: \`${data.review.decision}\``); + if (typeof data.review?.unresolvedThreads === "number") { + lines.push(`- Unresolved threads: ${data.review.unresolvedThreads}`); + } + if (typeof data.merge?.ready === "boolean") { + lines.push(`- Merge ready: ${data.merge.ready ? "yes" : "no"}`); + } + if (typeof data.merge?.conflicts === "boolean") { + lines.push(`- Conflicts: ${data.merge.conflicts ? "yes" : "no"}`); + } + if (typeof data.merge?.isBehind === "boolean") { + lines.push(`- Behind base: ${data.merge.isBehind ? "yes" : "no"}`); + } + if (data.reaction) { + lines.push(`- Reaction: \`${data.reaction.key}\` -> \`${data.reaction.action}\``); + } + if (data.escalation) { + lines.push(`- Escalation: ${data.escalation.attempts} attempts (${data.escalation.cause})`); + } + return lines; +} + +function formatCheckLine(check: NotificationCICheck): string { + const status = check.conclusion ? `${check.status}/${check.conclusion}` : check.status; + const name = check.url ? formatLink(check.name, check.url) : check.name; + return `- ${name}: \`${status}\``; +} + +function formatCheckLines(data: NotificationDataV3): string[] { + const checks = data.ci?.failedChecks ?? []; + return checks.slice(0, 8).map(formatCheckLine); +} + +function formatBlockerLines(data: NotificationDataV3): string[] { + const blockers = data.merge?.blockers ?? []; + return blockers.slice(0, 8).map((blocker) => `- ${blocker}`); +} + +function formatLinkLines(data: NotificationDataV3): string[] { + const links: string[] = []; + if (data.subject.pr?.url) links.push(`- ${formatLink("Pull request", data.subject.pr.url)}`); + if (data.review?.url) links.push(`- ${formatLink("Review", data.review.url)}`); + return links; +} + +function formatLegacyContext(data: Record): string[] { + return Object.entries(data) + .filter(([, value]) => compactValue(value) !== undefined) + .slice(0, 8) + .map(([key, value]) => `- ${key}: ${truncate(compactValue(value) ?? "")}`); } function formatEscalationMessage(event: OrchestratorEvent): string { - const parts = [eventHeadline(event), event.message, stringifyData(event.data)].filter(Boolean); - return parts.join("\n"); + const lines = [eventHeadline(event), "", event.message]; + const data = getNotificationDataV3(event.data); + + if (!data) { + pushSection(lines, "Session", [ + `- Project: \`${event.projectId}\``, + `- Session: \`${event.sessionId}\``, + ]); + pushSection(lines, "Context", formatLegacyContext(event.data)); + return lines.join("\n"); + } + + pushSection(lines, "Session", formatSubjectLines(data)); + pushSection(lines, "Pull Request", formatPrLines(data)); + pushSection(lines, "Status", formatStatusLines(data)); + pushSection(lines, "Checks", formatCheckLines(data)); + pushSection(lines, "Blockers", formatBlockerLines(data)); + pushSection(lines, "Links", formatLinkLines(data)); + return lines.join("\n"); } function formatActionsLine(actions: NotifyAction[]): string { if (actions.length === 0) return ""; - const labels = actions.map((a) => a.label).join(", "); - return `Actions available: ${labels}`; + const lines = actions.map((action) => { + const target = action.url ?? action.callbackEndpoint; + return target ? `- ${formatLink(action.label, target)}` : `- ${action.label}`; + }); + return ["", "**Actions**", ...lines].join("\n"); } /** @@ -283,10 +438,12 @@ export function create(config?: Record): Notifier { const url = (typeof config?.url === "string" ? config.url : undefined) ?? "http://127.0.0.1:18789/hooks/agent"; + const openclawConfigPath = + typeof config?.openclawConfigPath === "string" ? config.openclawConfigPath : undefined; const token = resolveEnvVarToken(config?.token) ?? - process.env.OPENCLAW_HOOKS_TOKEN ?? - readTokenFromOpenClawConfig(); + readTokenFromOpenClawConfig(openclawConfigPath) ?? + process.env.OPENCLAW_HOOKS_TOKEN; const senderName = typeof config?.name === "string" ? config.name : "AO"; const sessionKeyPrefix = typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:"; @@ -301,7 +458,7 @@ export function create(config?: Record): Notifier { if (!token) { console.warn( "[notifier-openclaw] No token configured.\n" + - " Set OPENCLAW_HOOKS_TOKEN env var, or add token to your notifier config.\n" + + " Add hooks.token to your OpenClaw config, or set notifiers.openclaw.openclawConfigPath.\n" + " Run: ao setup openclaw", ); } diff --git a/packages/plugins/notifier-slack/src/index.test.ts b/packages/plugins/notifier-slack/src/index.test.ts index 6c3166bfd..e4581ff6a 100644 --- a/packages/plugins/notifier-slack/src/index.test.ts +++ b/packages/plugins/notifier-slack/src/index.test.ts @@ -16,6 +16,14 @@ function makeEvent(overrides: Partial = {}): OrchestratorEven }; } +function makeV3Data(overrides: Record = {}): Record { + return { + schemaVersion: 3, + subject: { session: { id: "app-1", projectId: "my-project" } }, + ...overrides, + }; +} + function mockFetchOk() { return vi.fn().mockResolvedValue({ ok: true, @@ -23,6 +31,14 @@ function mockFetchOk() { }); } +function getSlackAttachment(body: Record): Record { + return body.attachments[0]; +} + +function getSlackBlocks(body: Record): Array> { + return getSlackAttachment(body).blocks; +} + describe("notifier-slack", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -149,11 +165,13 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ priority: "urgent", sessionId: "backend-3" })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const header = body.blocks[0]; + const blocks = getSlackBlocks(body); + const header = blocks[0]; expect(header.type).toBe("header"); expect(header.text.type).toBe("plain_text"); expect(header.text.text).toContain(":rotating_light:"); - expect(header.text.text).toContain("backend-3"); + expect(body.text).toContain("Session Spawned"); + expect(getSlackAttachment(body).color).toBe("#E01E5A"); }); it("uses correct emoji for each priority level", async () => { @@ -173,7 +191,7 @@ describe("notifier-slack", () => { fetchMock.mockClear(); await notifier.notify(makeEvent({ priority })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.blocks[0].text.text).toContain(emoji); + expect(getSlackBlocks(body)[0].text.text).toContain(emoji); } }); @@ -185,11 +203,27 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ message: "CI is green" })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const section = body.blocks[1]; + const section = getSlackBlocks(body)[1]; expect(section.type).toBe("section"); expect(section.text.text).toBe("CI is green"); }); + it("escapes user-controlled Slack mrkdwn characters", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify( + makeEvent({ message: "Fix *bold* _italic_ ~strike~ `code` & > done" }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const section = getSlackBlocks(body)[1]; + expect(section.text.text).toBe( + "Fix *bold* _italic_ ~strike~ `code` & <tag> > done", + ); + }); + it("includes context block with project and priority", async () => { const fetchMock = mockFetchOk(); vi.stubGlobal("fetch", fetchMock); @@ -198,13 +232,40 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ projectId: "frontend", priority: "action" })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const context = body.blocks[2]; - expect(context.type).toBe("context"); - expect(context.elements[0].text).toContain("*Project:* frontend"); - expect(context.elements[0].text).toContain("*Priority:* action"); + const fieldsBlock = getSlackBlocks(body).find((b) => b.type === "section" && b.fields)!; + expect(fieldsBlock).toBeDefined(); + expect(fieldsBlock.fields[0].text).toContain("*Project*"); + expect(fieldsBlock.fields[0].text).toContain("frontend"); + expect(fieldsBlock.fields[2].text).toContain("*Priority*"); + expect(fieldsBlock.fields[2].text).toContain("Action required"); }); - it("includes PR link when prUrl is a string in event data", async () => { + it("includes PR link when subject.pr.url is present in v3 data", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + subject: { + session: { id: "app-1", projectId: "my-project" }, + pr: { number: 42, url: "https://github.com/org/repo/pull/42" }, + }, + }), + }), + ); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => + b.type === "actions" && (b as any).elements?.[0]?.text?.text?.includes("View PR"), + )!; + expect(actionsBlock).toBeDefined(); + expect(actionsBlock.elements[0].url).toBe("https://github.com/org/repo/pull/42"); + }); + + it("ignores legacy flat prUrl", async () => { const fetchMock = mockFetchOk(); vi.stubGlobal("fetch", fetchMock); @@ -212,45 +273,14 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/42" } })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const prBlock = body.blocks.find( + const prBlock = getSlackBlocks(body).find( (b: Record) => - b.type === "section" && (b as any).text?.text?.includes("View Pull Request"), - ); - expect(prBlock).toBeDefined(); - expect(prBlock.text.text).toContain("https://github.com/org/repo/pull/42"); - }); - - it("ignores prUrl when it is not a string", async () => { - const fetchMock = mockFetchOk(); - vi.stubGlobal("fetch", fetchMock); - - const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { prUrl: 12345 } })); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const prBlock = body.blocks.find( - (b: Record) => - b.type === "section" && (b as any).text?.text?.includes("View Pull Request"), + b.type === "actions" && (b as any).elements?.[0]?.text?.text?.includes("View PR"), ); expect(prBlock).toBeUndefined(); }); - it("ignores ciStatus when it is not a string", async () => { - const fetchMock = mockFetchOk(); - vi.stubGlobal("fetch", fetchMock); - - const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { ciStatus: { nested: true } } })); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( - (b: Record) => - b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), - ); - expect(ciBlock).toBeUndefined(); - }); - - it("includes CI status when ciStatus is a string", async () => { + it("ignores legacy flat ciStatus", async () => { const fetchMock = mockFetchOk(); vi.stubGlobal("fetch", fetchMock); @@ -258,10 +288,25 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent({ data: { ciStatus: "passing" } })); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( + const ciBlock = getSlackBlocks(body).find( (b: Record) => b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), ); + expect(ciBlock).toBeUndefined(); + }); + + it("includes CI status when ci.status is present in v3 data", async () => { + const fetchMock = mockFetchOk(); + vi.stubGlobal("fetch", fetchMock); + + const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); + await notifier.notify(makeEvent({ data: makeV3Data({ ci: { status: "passing" } }) })); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + const ciBlock = getSlackBlocks(body).find( + (b: Record) => + b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), + )!; expect(ciBlock).toBeDefined(); expect(ciBlock.elements[0].text).toContain(":white_check_mark:"); }); @@ -271,14 +316,24 @@ describe("notifier-slack", () => { vi.stubGlobal("fetch", fetchMock); const notifier = create({ webhookUrl: "https://hooks.slack.com/test" }); - await notifier.notify(makeEvent({ data: { ciStatus: "failing" } })); + await notifier.notify( + makeEvent({ + data: makeV3Data({ + ci: { + status: "failing", + failedChecks: [{ name: "typecheck", status: "failed" }], + }, + }), + }), + ); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const ciBlock = body.blocks.find( + const ciBlock = getSlackBlocks(body).find( (b: Record) => b.type === "context" && (b as any).elements?.[0]?.text?.includes("CI:"), - ); + )!; expect(ciBlock.elements[0].text).toContain(":x:"); + expect(ciBlock.elements[0].text).toContain("typecheck"); }); it("ends with a divider block", async () => { @@ -289,7 +344,8 @@ describe("notifier-slack", () => { await notifier.notify(makeEvent()); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const lastBlock = body.blocks[body.blocks.length - 1]; + const blocks = getSlackBlocks(body); + const lastBlock = blocks[blocks.length - 1]; expect(lastBlock.type).toBe("divider"); }); }); @@ -307,7 +363,9 @@ describe("notifier-slack", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => b.type === "actions", + )!; expect(actionsBlock).toBeDefined(); expect(actionsBlock.elements).toHaveLength(2); expect(actionsBlock.elements[0].type).toBe("button"); @@ -325,9 +383,12 @@ describe("notifier-slack", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => b.type === "actions", + )!; expect(actionsBlock.elements[0].action_id).toBe("ao_kill_session_0"); expect(actionsBlock.elements[0].value).toBe("/api/sessions/app-1/kill"); + expect(actionsBlock.elements[0].style).toBe("danger"); }); it("filters out actions with no url or callback", async () => { @@ -342,7 +403,9 @@ describe("notifier-slack", () => { await notifier.notifyWithActions!(makeEvent(), actions); const body = JSON.parse(fetchMock.mock.calls[0][1].body); - const actionsBlock = body.blocks.find((b: Record) => b.type === "actions"); + const actionsBlock = getSlackBlocks(body).find( + (b: Record) => b.type === "actions", + )!; expect(actionsBlock.elements).toHaveLength(1); expect(actionsBlock.elements[0].text.text).toBe("Merge"); }); diff --git a/packages/plugins/notifier-slack/src/index.ts b/packages/plugins/notifier-slack/src/index.ts index 4f122dfad..7a70a9ef6 100644 --- a/packages/plugins/notifier-slack/src/index.ts +++ b/packages/plugins/notifier-slack/src/index.ts @@ -1,4 +1,5 @@ import { + getNotificationDataV3, validateUrl, type PluginModule, type Notifier, @@ -6,6 +7,7 @@ import { type NotifyAction, type NotifyContext, type EventPriority, + type NotificationDataV3, CI_STATUS, } from "@aoagents/ao-core"; @@ -16,20 +18,287 @@ export const manifest = { version: "0.1.0", }; -const PRIORITY_EMOJI: Record = { - urgent: ":rotating_light:", - action: ":point_right:", - warning: ":warning:", - info: ":information_source:", +interface SlackTone { + emoji: string; + label: string; + color: string; +} + +interface SlackButton { + type: "button"; + text: { + type: "plain_text"; + text: string; + emoji: true; + }; + url?: string; + action_id?: string; + value?: string; + style?: "primary" | "danger"; +} + +interface SlackAttachment { + color: string; + fallback: string; + blocks: unknown[]; +} + +const SUCCESS_TONE: SlackTone = { + emoji: ":white_check_mark:", + label: "Complete", + color: "#2EB67D", }; -function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknown[] { +const PRIORITY_TONE: Record = { + urgent: { + emoji: ":rotating_light:", + label: "Urgent", + color: "#E01E5A", + }, + action: { + emoji: ":point_right:", + label: "Action required", + color: "#6157D8", + }, + warning: { + emoji: ":warning:", + label: "Warning", + color: "#ECB22E", + }, + info: { + emoji: ":information_source:", + label: "Information", + color: "#36C5F0", + }, +}; + +function escapeSlackText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\*/g, "*") + .replace(/_/g, "_") + .replace(/~/g, "~") + .replace(/`/g, "`"); +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; +} + +function titleCaseStatus(value: string): string { + return value + .split(/[_\s.-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function formatSlackDate(date: Date): string { + const timestamp = Math.floor(date.getTime() / 1000); + return ``; +} + +function toneForEvent(event: OrchestratorEvent): SlackTone { + if (event.type === "merge.ready") { + return { ...SUCCESS_TONE, label: "Ready to merge" }; + } + if (event.type === "summary.all_complete") { + return { ...SUCCESS_TONE, label: "All complete" }; + } + if (event.type === "ci.failing" || event.type === "session.stuck") { + return PRIORITY_TONE.urgent; + } + if (event.type === "review.changes_requested") { + return PRIORITY_TONE.warning; + } + return PRIORITY_TONE[event.priority] ?? PRIORITY_TONE.info; +} + +function eventTitle(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const pr = data?.subject.pr; + + switch (event.type) { + case "ci.failing": + return pr ? `CI failing on PR #${pr.number}` : "CI failing"; + case "merge.ready": + return pr ? `PR #${pr.number} ready to merge` : "Pull request ready to merge"; + case "review.changes_requested": + return pr ? `Changes requested on PR #${pr.number}` : "Review changes requested"; + case "session.needs_input": + return "Agent needs input"; + case "session.stuck": + return "Agent may be stuck"; + case "session.killed": + case "session.exited": + return "Agent exited"; + case "pr.closed": + return pr ? `PR #${pr.number} closed` : "Pull request closed"; + case "summary.all_complete": + return "All sessions complete"; + default: + return titleCaseStatus(event.type); + } +} + +function formatField(label: string, value: string | number | boolean | undefined | null): unknown { + return { + type: "mrkdwn", + text: `*${escapeSlackText(label)}*\n${escapeSlackText( + value === undefined || value === null || value === "" ? "Not available" : String(value), + )}`, + }; +} + +function buildFieldBlocks(event: OrchestratorEvent, data: NotificationDataV3 | null): unknown[] { + const pr = data?.subject.pr; + const issue = data?.subject.issue; + const branch = + pr?.branch && pr.baseBranch + ? `${pr.branch} -> ${pr.baseBranch}` + : (pr?.branch ?? pr?.baseBranch ?? data?.subject.branch); + const fields = [ + formatField("Project", event.projectId), + formatField("Session", event.sessionId), + formatField("Priority", toneForEvent(event).label), + ...(pr + ? [formatField("Pull Request", `#${pr.number}${pr.title ? ` - ${pr.title}` : ""}`)] + : []), + ...(branch ? [formatField("Branch", branch)] : []), + ...(issue + ? [formatField("Issue", `${issue.id}${issue.title ? ` - ${issue.title}` : ""}`)] + : []), + ...(data?.ci?.status ? [formatField("CI", titleCaseStatus(data.ci.status))] : []), + ...(data?.review?.decision + ? [formatField("Review", titleCaseStatus(data.review.decision))] + : []), + ...(typeof data?.merge?.ready === "boolean" + ? [formatField("Merge", data.merge.ready ? "Ready" : "Not ready")] + : []), + ...(typeof data?.merge?.isBehind === "boolean" + ? [formatField("Sync", data.merge.isBehind ? "Behind base" : "Up to date")] + : []), + ].slice(0, 10); + + if (fields.length === 0) return []; + return [{ type: "section", fields }]; +} + +function buildStatusContext(data: NotificationDataV3 | null): unknown[] { + if (!data) return []; + const context: string[] = []; + + if (data.ci?.status) { + const ciEmoji = data.ci.status === CI_STATUS.PASSING ? ":white_check_mark:" : ":x:"; + const failedChecks = data.ci.failedChecks?.map((check) => escapeSlackText(check.name)) ?? []; + const failedText = failedChecks.length > 0 ? ` | Failed: ${failedChecks.join(", ")}` : ""; + context.push(`${ciEmoji} CI: ${escapeSlackText(data.ci.status)}${failedText}`); + } + + if (typeof data.merge?.conflicts === "boolean") { + context.push( + data.merge.conflicts + ? ":x: Merge conflicts detected" + : ":white_check_mark: No merge conflicts", + ); + } + + if (typeof data.review?.unresolvedThreads === "number") { + context.push(`:speech_balloon: Review threads: ${data.review.unresolvedThreads}`); + } + + if (data.merge?.blockers?.length) { + context.push( + `:no_entry: Blockers: ${data.merge.blockers.slice(0, 5).map(escapeSlackText).join(", ")}`, + ); + } + + if (context.length === 0) return []; + return [ + { + type: "context", + elements: [{ type: "mrkdwn", text: context.join(" • ") }], + }, + ]; +} + +function sanitizeActionId(label: string, index: number): string { + const sanitized = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, ""); + return `ao_${sanitized ? `${sanitized}_${index}` : `action_${index}`}`; +} + +function buildButton(label: string, url: string, style?: "primary" | "danger"): SlackButton { + return { + type: "button", + text: { type: "plain_text", text: truncate(label, 75), emoji: true }, + url, + ...(style ? { style } : {}), + }; +} + +function buildActionElements( + data: NotificationDataV3 | null, + actions?: NotifyAction[], +): SlackButton[] { + const elements: SlackButton[] = []; + const seenUrls = new Set(); + const prUrl = data?.subject.pr?.url; + const reviewUrl = data?.review?.url; + + if (prUrl) { + elements.push(buildButton("View PR", prUrl, "primary")); + seenUrls.add(prUrl); + } + if (reviewUrl && !seenUrls.has(reviewUrl)) { + elements.push(buildButton("View Review", reviewUrl)); + seenUrls.add(reviewUrl); + } + + for (const [index, action] of (actions ?? []).entries()) { + if (action.url) { + if (seenUrls.has(action.url)) continue; + elements.push( + buildButton(action.label, action.url, elements.length === 0 ? "primary" : undefined), + ); + seenUrls.add(action.url); + continue; + } + if (!action.callbackEndpoint) continue; + + const label = truncate(action.label, 75); + const lower = label.toLowerCase(); + elements.push({ + type: "button", + text: { type: "plain_text", text: label, emoji: true }, + action_id: sanitizeActionId(label, index), + value: action.callbackEndpoint, + ...(lower.includes("kill") || lower.includes("cancel") ? { style: "danger" } : {}), + }); + } + + return elements.slice(0, 5); +} + +function buildFallbackText(event: OrchestratorEvent, data: NotificationDataV3 | null): string { + const tone = toneForEvent(event); + return `${tone.label}: ${eventTitle(event, data)} — ${event.message}`; +} + +function buildAttachment(event: OrchestratorEvent, actions?: NotifyAction[]): SlackAttachment { + const data = getNotificationDataV3(event.data); + const tone = toneForEvent(event); + const title = eventTitle(event, data); + const subtitle = data?.subject.pr?.title ?? data?.subject.summary; const blocks: unknown[] = [ { type: "header", text: { type: "plain_text", - text: `${PRIORITY_EMOJI[event.priority]} ${event.type} — ${event.sessionId}`, + text: truncate(`${tone.emoji} ${title}`, 150), emoji: true, }, }, @@ -37,84 +306,37 @@ function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknow type: "section", text: { type: "mrkdwn", - text: event.message, + text: `${subtitle ? `*${escapeSlackText(subtitle)}*\n` : ""}${escapeSlackText(event.message)}`, }, }, + ...buildFieldBlocks(event, data), + ...buildStatusContext(data), { type: "context", elements: [ { type: "mrkdwn", - text: `*Project:* ${event.projectId} | *Priority:* ${event.priority} | *Time:* `, + text: `Sent by Agent Orchestrator • ${formatSlackDate(event.timestamp)}`, }, ], }, ]; - // Add PR link if available (type-guarded) - const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; - if (prUrl) { + const actionElements = buildActionElements(data, actions); + if (actionElements.length > 0) { blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `:github: <${prUrl}|View Pull Request>`, - }, + type: "actions", + elements: actionElements, }); } - // Add CI status if available (type-guarded) - const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; - if (ciStatus) { - const ciEmoji = ciStatus === CI_STATUS.PASSING ? ":white_check_mark:" : ":x:"; - blocks.push({ - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${ciEmoji} CI: ${ciStatus}`, - }, - ], - }); - } - - // Add action buttons - if (actions && actions.length > 0) { - const elements = actions - .filter((a) => a.url || a.callbackEndpoint) - .map((action) => { - if (action.url) { - return { - type: "button", - text: { type: "plain_text", text: action.label, emoji: true }, - url: action.url, - }; - } - const sanitized = action.label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_|_$/g, ""); - const idx = actions.indexOf(action); - const actionId = sanitized ? `${sanitized}_${idx}` : `action_${idx}`; - return { - type: "button", - text: { type: "plain_text", text: action.label, emoji: true }, - action_id: `ao_${actionId}`, - value: action.callbackEndpoint, - }; - }); - - if (elements.length > 0) { - blocks.push({ - type: "actions", - elements, - }); - } - } - blocks.push({ type: "divider" }); - return blocks; + return { + color: tone.color, + fallback: buildFallbackText(event, data), + blocks, + }; } async function postToWebhook(webhookUrl: string, payload: Record): Promise { @@ -147,9 +369,11 @@ export function create(config?: Record): Notifier { async notify(event: OrchestratorEvent): Promise { if (!webhookUrl) return; + const attachment = buildAttachment(event); const payload: Record = { username, - blocks: buildBlocks(event), + text: attachment.fallback, + attachments: [attachment], }; if (defaultChannel) payload.channel = defaultChannel; @@ -159,9 +383,11 @@ export function create(config?: Record): Notifier { async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { if (!webhookUrl) return; + const attachment = buildAttachment(event, actions); const payload: Record = { username, - blocks: buildBlocks(event, actions), + text: attachment.fallback, + attachments: [attachment], }; if (defaultChannel) payload.channel = defaultChannel; diff --git a/packages/web/server/__tests__/mux-websocket.test.ts b/packages/web/server/__tests__/mux-websocket.test.ts index 3e9c41377..db6ed9548 100644 --- a/packages/web/server/__tests__/mux-websocket.test.ts +++ b/packages/web/server/__tests__/mux-websocket.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { Socket } from "node:net"; import { WebSocket } from "ws"; +import { appendDashboardNotification, type OrchestratorEvent } from "@aoagents/ao-core"; import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket"; // vi.mock factories run before module-level statements. Hoist the mock @@ -49,8 +53,13 @@ vi.mock("../tmux-utils.js", () => ({ tmuxHasSession: (...args: unknown[]) => mockTmuxHasSession(...args), })); -const { SessionBroadcaster, TerminalManager, createMuxWebSocket, handleWindowsPipeMessage } = - await import("../mux-websocket"); +const { + NotificationBroadcaster, + SessionBroadcaster, + TerminalManager, + createMuxWebSocket, + handleWindowsPipeMessage, +} = await import("../mux-websocket"); // Mock global fetch const mockFetch = vi.fn(); @@ -771,6 +780,144 @@ describe("TerminalManager ui.terminal_pty_lost activity events", () => { }); }); +describe("NotificationBroadcaster", () => { + let tempDir: string | null = null; + let configPath: string; + + beforeEach(() => { + vi.useFakeTimers(); + tempDir = mkdtempSync(join(tmpdir(), "ao-notification-broadcaster-")); + configPath = join(tempDir, "agent-orchestrator.yaml"); + writeFileSync( + configPath, + [ + "projects: {}", + "notifiers:", + " dashboard:", + " plugin: dashboard", + " limit: 2", + "", + ].join("\n"), + ); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + function makeEvent(id: string): OrchestratorEvent { + return { + id, + type: "session.needs_input", + priority: "action", + sessionId: "worker-1", + projectId: "demo", + timestamp: new Date("2026-05-13T12:00:00.000Z"), + message: `Event ${id}`, + data: {}, + }; + } + + function appendEvent(id: string, receivedAt: string): void { + appendDashboardNotification(configPath, makeEvent(id), undefined, { + receivedAt: new Date(receivedAt), + limit: 2, + }); + } + + function appendEventWithLimit(id: string, receivedAt: string, limit: number): void { + appendDashboardNotification(configPath, makeEvent(id), undefined, { + receivedAt: new Date(receivedAt), + limit, + }); + } + + it("sends an immediate dashboard notification snapshot", () => { + appendEvent("evt-1", "2026-05-13T12:00:01.000Z"); + const broadcaster = new NotificationBroadcaster(configPath); + const callback = vi.fn(); + + const unsubscribe = broadcaster.subscribe(callback); + + expect(callback).toHaveBeenCalledWith( + [expect.objectContaining({ event: expect.objectContaining({ id: "evt-1" }) })], + "snapshot", + 2, + ); + + unsubscribe(); + }); + + it("does not let a new subscriber suppress appends for existing subscribers", () => { + appendEvent("evt-1", "2026-05-13T12:00:01.000Z"); + const broadcaster = new NotificationBroadcaster(configPath); + const first = vi.fn(); + const second = vi.fn(); + + const unsubscribeFirst = broadcaster.subscribe(first); + appendEvent("evt-2", "2026-05-13T12:00:02.000Z"); + const unsubscribeSecond = broadcaster.subscribe(second); + + vi.advanceTimersByTime(1000); + + expect(first).toHaveBeenLastCalledWith( + [expect.objectContaining({ event: expect.objectContaining({ id: "evt-2" }) })], + "append", + 2, + ); + expect(second).toHaveBeenCalledWith( + [ + expect.objectContaining({ event: expect.objectContaining({ id: "evt-1" }) }), + expect.objectContaining({ event: expect.objectContaining({ id: "evt-2" }) }), + ], + "snapshot", + 2, + ); + + unsubscribeFirst(); + unsubscribeSecond(); + }); + + it("reloads the dashboard notification limit while polling", () => { + appendEvent("evt-1", "2026-05-13T12:00:01.000Z"); + const broadcaster = new NotificationBroadcaster(configPath); + const callback = vi.fn(); + + const unsubscribe = broadcaster.subscribe(callback); + expect(callback).toHaveBeenCalledWith(expect.any(Array), "snapshot", 2); + + writeFileSync( + configPath, + [ + "projects: {}", + "notifiers:", + " dashboard:", + " plugin: dashboard", + " limit: 3", + "", + ].join("\n"), + ); + appendEventWithLimit("evt-2", "2026-05-13T12:00:02.000Z", 3); + appendEventWithLimit("evt-3", "2026-05-13T12:00:03.000Z", 3); + + vi.advanceTimersByTime(1000); + + expect(callback).toHaveBeenLastCalledWith( + [ + expect.objectContaining({ event: expect.objectContaining({ id: "evt-2" }) }), + expect.objectContaining({ event: expect.objectContaining({ id: "evt-3" }) }), + ], + "append", + 3, + ); + + unsubscribe(); + }); +}); + describe("TerminalManager.open — tmux target args (regression for #1714)", () => { beforeEach(() => { mockSpawn.mockReset(); diff --git a/packages/web/server/mux-websocket.ts b/packages/web/server/mux-websocket.ts index 5db3d4630..6b14436f2 100644 --- a/packages/web/server/mux-websocket.ts +++ b/packages/web/server/mux-websocket.ts @@ -9,6 +9,17 @@ import { WebSocketServer, WebSocket } from "ws"; import { spawn } from "node:child_process"; import { type Socket, connect as netConnect } from "node:net"; +import { + DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + getEnvDefaults, + getDashboardNotificationStorePath, + isWindows, + loadConfig, + normalizeDashboardNotificationLimit, + recordActivityEvent, + readDashboardNotificationsFromFile, + type DashboardNotificationRecord, +} from "@aoagents/ao-core"; import { findTmux, resolveTmuxSession, @@ -16,7 +27,6 @@ import { tmuxHasSession, validateSessionId, } from "./tmux-utils.js"; -import { getEnvDefaults, isWindows, recordActivityEvent } from "@aoagents/ao-core"; // These types mirror src/lib/mux-protocol.ts exactly. // tsconfig.server.json constrains rootDir to "server/", so we cannot import @@ -29,7 +39,7 @@ type ClientMessage = | { ch: "terminal"; id: string; type: "open"; projectId?: string; tmuxName?: string } | { ch: "terminal"; id: string; type: "close"; projectId?: string } | { ch: "system"; type: "ping" } - | { ch: "subscribe"; topics: "sessions"[] }; + | { ch: "subscribe"; topics: Array<"sessions" | "notifications"> }; // ── Server → Client ── type ServerMessage = @@ -39,6 +49,13 @@ type ServerMessage = | { ch: "terminal"; id: string; type: "error"; message: string; projectId?: string } | { ch: "sessions"; type: "snapshot"; sessions: SessionPatch[] } | { ch: "sessions"; type: "error"; error: string } + | { + ch: "notifications"; + type: "snapshot" | "append"; + notifications: DashboardNotificationRecord[]; + limit: number; + } + | { ch: "notifications"; type: "error"; error: string } | { ch: "system"; type: "pong" } | { ch: "system"; type: "error"; message: string }; @@ -205,6 +222,161 @@ export class SessionBroadcaster { } } +function notificationKey(record: DashboardNotificationRecord): string { + return `${record.id}:${record.receivedAt}`; +} + +function readDashboardLimit(configPath: string | undefined): number { + if (!configPath) return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + try { + const config = loadConfig(configPath); + const dashboardConfig = config.notifiers?.["dashboard"]; + return normalizeDashboardNotificationLimit(dashboardConfig?.["limit"]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[NotificationBroadcaster] Could not read dashboard notifier limit:", message); + return DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; + } +} + +/** + * Polls the dashboard notification JSONL store and broadcasts changes to mux + * subscribers. The store is config-scoped and survives dashboard reloads. + */ +export class NotificationBroadcaster { + private subscribers = new Set< + ( + notifications: DashboardNotificationRecord[], + type: "snapshot" | "append", + limit: number, + ) => void + >(); + private errorSubscribers = new Set<(error: string) => void>(); + private intervalId: ReturnType | null = null; + private lastRecords: DashboardNotificationRecord[] = []; + private readonly configPath: string | undefined; + private readonly storePath: string | null; + + constructor(configPath = process.env["AO_CONFIG_PATH"]) { + this.configPath = configPath; + this.storePath = configPath ? getDashboardNotificationStorePath(configPath) : null; + } + + subscribe( + callback: ( + notifications: DashboardNotificationRecord[], + type: "snapshot" | "append", + limit: number, + ) => void, + onError?: (error: string) => void, + ): () => void { + const wasEmpty = this.subscribers.size === 0; + this.subscribers.add(callback); + if (onError) this.errorSubscribers.add(onError); + + const snapshot = this.fetchSnapshot(); + if (wasEmpty) { + this.lastRecords = snapshot.notifications; + } + try { + callback(snapshot.notifications, "snapshot", snapshot.limit); + } catch { + // Isolate subscriber errors so one bad socket does not break others. + } + + if (snapshot.error && onError) { + try { + onError(snapshot.error); + } catch { + // Isolate subscriber errors. + } + } + + if (wasEmpty) { + this.intervalId = setInterval(() => { + const result = this.fetchSnapshot(); + if (result.error) { + this.broadcastError(result.error); + return; + } + + const previousKeys = new Set(this.lastRecords.map(notificationKey)); + const appended = result.notifications.filter( + (record) => !previousKeys.has(notificationKey(record)), + ); + const trimmed = result.notifications.length < this.lastRecords.length; + this.lastRecords = result.notifications; + + if (appended.length > 0 && !trimmed) { + this.broadcast(appended, "append", result.limit); + } else if (appended.length > 0 || trimmed) { + this.broadcast(result.notifications, "snapshot", result.limit); + } + }, 1000); + } + + return () => { + this.subscribers.delete(callback); + if (onError) this.errorSubscribers.delete(onError); + if (this.subscribers.size === 0) { + this.disconnect(); + } + }; + } + + private fetchSnapshot(): { + notifications: DashboardNotificationRecord[]; + error: string | null; + limit: number; + } { + const limit = readDashboardLimit(this.configPath); + if (!this.storePath) return { notifications: [], error: null, limit }; + + try { + return { + notifications: readDashboardNotificationsFromFile(this.storePath, limit), + error: null, + limit, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[NotificationBroadcaster] fetchSnapshot error:", message); + return { notifications: [], error: message, limit }; + } + } + + private broadcast( + notifications: DashboardNotificationRecord[], + type: "snapshot" | "append", + limit: number, + ): void { + for (const callback of this.subscribers) { + try { + callback(notifications, type, limit); + } catch (err) { + console.error("[MuxServer] Notification broadcast subscriber threw:", err); + } + } + } + + private broadcastError(error: string): void { + for (const callback of this.errorSubscribers) { + try { + callback(error); + } catch (err) { + console.error("[MuxServer] Notification error subscriber threw:", err); + } + } + } + + private disconnect(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } +} + // node-pty is an optionalDependency — load dynamically /* eslint-disable @typescript-eslint/consistent-type-imports -- node-pty is optional; static import would crash if missing */ type IPty = import("node-pty").IPty; @@ -835,6 +1007,7 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | const nextPort = process.env.PORT || "3000"; const broadcaster = new SessionBroadcaster(nextPort); + const notificationBroadcaster = new NotificationBroadcaster(); const wss = new WebSocketServer({ noServer: true }); @@ -864,6 +1037,7 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | // Windows: framing buffers keyed by session ID const winPipeBuffers = new Map(); let sessionUnsubscribe: (() => void) | null = null; + let notificationUnsubscribe: (() => void) | null = null; let missedPongs = 0; let heartbeatLostEmitted = false; const MAX_MISSED_PONGS = 3; @@ -1086,6 +1260,29 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | }, ); } + if (msg.topics.includes("notifications") && !notificationUnsubscribe) { + notificationUnsubscribe = notificationBroadcaster.subscribe( + (notifications, type, limit) => { + if (ws.readyState !== WebSocket.OPEN) return; + if (ws.bufferedAmount > WS_BUFFER_HIGH_WATERMARK) { + console.warn("[MuxServer] Skipping notification update — socket backpressured"); + return; + } + const msg: ServerMessage = { + ch: "notifications", + type, + notifications, + limit, + }; + ws.send(JSON.stringify(msg)); + }, + (error) => { + if (ws.readyState !== WebSocket.OPEN) return; + const errMsg: ServerMessage = { ch: "notifications", type: "error", error }; + ws.send(JSON.stringify(errMsg)); + }, + ); + } } } catch (err) { console.error("[MuxServer] Failed to parse message:", err); @@ -1133,6 +1330,8 @@ export function createMuxWebSocket(tmuxPath?: string | null): WebSocketServer | clearInterval(heartbeatInterval); sessionUnsubscribe?.(); sessionUnsubscribe = null; + notificationUnsubscribe?.(); + notificationUnsubscribe = null; for (const unsub of subscriptions.values()) { unsub(); } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index f7e239966..d9c4894d9 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1463,6 +1463,412 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 0; } +.dashboard-notification-wrap { + position: relative; + display: inline-flex; +} + +.dashboard-notification-btn { + min-width: 31px; + justify-content: center; + padding: 0 8px; +} + +.dashboard-notification-btn--open { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.dashboard-notification-btn__count { + min-width: 15px; + height: 15px; + padding: 0 4px; + border-radius: 999px; + background: var(--color-status-attention); + color: var(--color-bg-base); + font-size: 10px; + line-height: 15px; + text-align: center; +} + +.dashboard-notification-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 55; + width: 430px; + max-width: calc(100vw - 24px); + max-height: min(520px, calc(100vh - 72px)); + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--color-border-subtle); + background: var(--color-bg-elevated); + box-shadow: + 0 18px 42px rgba(0, 0, 0, 0.42), + 0 1px 0 rgba(255, 255, 255, 0.04) inset; +} + +.dashboard-notification-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px 4px; + background: color-mix(in srgb, var(--color-bg-muted) 38%, transparent); +} + +.dashboard-notification-panel__title { + color: var(--color-text-primary); + font-size: 15px; + font-weight: 600; + line-height: 1.2; +} + +.dashboard-notification-panel__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.dashboard-notification-panel__mark-all { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + white-space: nowrap; + border: 1px solid var(--color-border-subtle); + border-radius: 5px; + background: transparent; + color: var(--color-text-secondary); + padding: 0 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.dashboard-notification-panel__mark-all:hover:not(:disabled) { + border-color: var(--color-border-strong); + background: color-mix(in srgb, var(--color-bg-subtle) 80%, transparent); + color: var(--color-text-primary); +} + +.dashboard-notification-panel__mark-all:disabled { + cursor: default; + opacity: 0.45; +} + +.dashboard-notification-tabs { + display: flex; + gap: 18px; + padding: 0 14px; + border-bottom: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-bg-muted) 38%, transparent); +} + +.dashboard-notification-tab { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + border: 0; + background: transparent; + color: var(--color-text-secondary); + padding: 0; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.dashboard-notification-tab span { + min-width: 20px; + height: 20px; + border-radius: 999px; + background: var(--color-bg-muted); + color: var(--color-text-secondary); + padding: 0 6px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 20px; + text-align: center; +} + +.dashboard-notification-tab:hover { + color: var(--color-text-primary); +} + +.dashboard-notification-tab[aria-selected="true"] { + color: var(--color-text-primary); +} + +.dashboard-notification-tab[aria-selected="true"]::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + border-radius: 999px; + background: var(--color-text-primary); + content: ""; +} + +.dashboard-notification-tab[aria-selected="true"] span { + background: color-mix(in srgb, var(--color-bg-subtle) 90%, var(--color-bg-muted)); + color: var(--color-text-primary); +} + +.dashboard-notification-panel__error { + margin: 10px; + border: 1px solid color-mix(in srgb, var(--color-status-error) 28%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--color-status-error) 10%, transparent); + color: var(--color-status-error); + padding: 8px; + font-size: 11px; + line-height: 1.4; +} + +.dashboard-notification-panel__empty { + padding: 18px 12px; + color: var(--color-text-muted); + font-size: 12px; + text-align: center; +} + +.dashboard-notification-list { + display: flex; + max-height: 448px; + flex-direction: column; + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; +} + +.dashboard-notification-item { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + gap: 10px; + border-left: 3px solid var(--color-border-default); + border-bottom: 1px solid var(--color-border-subtle); + padding: 12px; +} + +.dashboard-notification-item:hover { + background: var(--color-bg-subtle); +} + +.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-accent) 6%, transparent); +} + +.dashboard-notification-item--read { + opacity: 0.72; +} + +.dashboard-notification-item--urgent { + border-left-color: var(--color-status-error); +} + +.dashboard-notification-item--urgent.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-status-error) 8%, transparent); +} + +.dashboard-notification-item--action { + border-left-color: var(--color-status-attention); +} + +.dashboard-notification-item--action.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-status-attention) 9%, transparent); +} + +.dashboard-notification-item--warning { + border-left-color: var(--color-accent-amber); +} + +.dashboard-notification-item--info { + border-left-color: var(--color-accent); +} + +.dashboard-notification-item--success { + border-left-color: var(--color-accent-green); +} + +.dashboard-notification-item--success.dashboard-notification-item--unread { + background: color-mix(in srgb, var(--color-accent-green) 8%, transparent); +} + +.dashboard-notification-item__status-dot { + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 999px; + background: var(--color-border-strong); +} + +.dashboard-notification-item--unread .dashboard-notification-item__status-dot { + background: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 14%, transparent); +} + +.dashboard-notification-item--urgent.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-status-error); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-status-error) 14%, transparent); +} + +.dashboard-notification-item--action.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-status-attention); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-status-attention) 14%, transparent); +} + +.dashboard-notification-item--warning.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-accent-amber); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-amber) 14%, transparent); +} + +.dashboard-notification-item--success.dashboard-notification-item--unread + .dashboard-notification-item__status-dot { + background: var(--color-accent-green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent-green) 14%, transparent); +} + +.dashboard-notification-item__content { + min-width: 0; +} + +.dashboard-notification-item__topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} + +.dashboard-notification-item__priority { + display: inline-flex; + align-items: center; + height: 18px; + border-radius: 4px; + background: var(--color-bg-muted); + color: var(--color-text-secondary); + padding: 0 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.dashboard-notification-item--success .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-accent-green) 12%, transparent); + color: var(--color-accent-green); +} + +.dashboard-notification-item--urgent .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-status-error) 12%, transparent); + color: var(--color-status-error); +} + +.dashboard-notification-item--action .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-status-attention) 14%, transparent); + color: var(--color-status-attention); +} + +.dashboard-notification-item--warning .dashboard-notification-item__priority { + background: color-mix(in srgb, var(--color-accent-amber) 14%, transparent); + color: var(--color-accent-amber); +} + +.dashboard-notification-item__time { + color: var(--color-text-muted); + font-size: 10px; + font-family: var(--font-mono); +} + +.dashboard-notification-item__message { + margin: 0; + color: var(--color-text-primary); + font-size: 12px; + font-weight: 500; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.dashboard-notification-item__meta { + display: flex; + min-width: 0; + gap: 8px; + margin-top: 5px; + color: var(--color-text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.dashboard-notification-item__meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-notification-item__links { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.dashboard-notification-item__links a { + display: inline-flex; + align-items: center; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 4px; + background: var(--color-bg-muted); + color: var(--color-accent); + padding: 2px 6px; + font-size: 11px; + text-decoration: none; +} + +.dashboard-notification-item__links a:hover { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + text-decoration: none; +} + +.dashboard-notification-item__side { + display: flex; + align-items: flex-start; +} + +.dashboard-notification-item__read-btn { + height: 22px; + white-space: nowrap; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--color-accent); + padding: 0 6px; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.dashboard-notification-item__read-btn:hover { + border-color: color-mix(in srgb, var(--color-accent) 30%, transparent); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); +} + +.dashboard-notification-item__read-label { + color: var(--color-text-muted); + font-size: 11px; +} + /* Session title hidden on mobile (pills move to second row instead) */ @media (max-width: 640px) { .dashboard-app-header__session-title { @@ -1477,6 +1883,19 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { left: 0; width: calc(100vw - 16px); } + + .dashboard-notification-panel { + right: -6px; + width: min(430px, calc(100vw - 16px)); + } + + .dashboard-notification-item { + grid-template-columns: 10px minmax(0, 1fr); + } + + .dashboard-notification-item__side { + grid-column: 2; + } } .dashboard-shell--desktop { diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 9044ff2bc..dd8dc71ca 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -24,6 +24,7 @@ import { ToastProvider, useToast } from "./Toast"; import { ConnectionBar } from "./ConnectionBar"; import { UpdateBanner } from "./UpdateBanner"; import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; +import { DashboardNotificationButton } from "./DashboardNotificationButton"; import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext"; import { ProjectSidebar } from "./ProjectSidebar"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; @@ -664,6 +665,7 @@ function DashboardInner({
{showDebugBundleButton ? : null} + {!allProjectsView && orchestratorHref ? ( = { + "github.com": "https://github.com", + "gitlab.com": "https://gitlab.com", + "linear.app": "https://linear.app", +}; + +type NotificationView = "all" | "unread"; + +function formatRelativeTime(isoDate: string): string { + const timestamp = new Date(isoDate).getTime(); + if (!Number.isFinite(timestamp)) return "now"; + + const diffMs = Date.now() - timestamp; + if (diffMs < 60_000) return "now"; + + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + + return `${Math.floor(hours / 24)}d`; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringField(data: Record, key: string): string | null { + const value = data[key]; + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function booleanField(data: Record, key: string): boolean | null { + const value = data[key]; + return typeof value === "boolean" ? value : null; +} + +function recordField(data: Record, key: string): Record | null { + const value = data[key]; + return isRecord(value) ? value : null; +} + +function notificationDataV3( + notification: DashboardNotificationRecord, +): Record | null { + const data = notification.event.data; + return isRecord(data) && data.schemaVersion === 3 ? data : null; +} + +function getSubjectPR(notification: DashboardNotificationRecord): Record | null { + const data = notificationDataV3(notification); + if (!data) return null; + const subject = recordField(data, "subject"); + return subject ? recordField(subject, "pr") : null; +} + +function getPRUrl(notification: DashboardNotificationRecord): string | null { + const pr = getSubjectPR(notification); + return pr ? stringField(pr, "url") : null; +} + +function getReviewUrl(notification: DashboardNotificationRecord): string | null { + const data = notificationDataV3(notification); + if (!data) return null; + const review = recordField(data, "review"); + return review ? stringField(review, "url") : null; +} + +function normalizeActionText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function canonicalUrl(value: string | null | undefined): string | null { + const safeHref = safeExternalHref(value); + if (!safeHref) return null; + + try { + const url = new URL(safeHref); + url.hash = ""; + const pathname = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${pathname}${url.search}`; + } catch { + return null; + } +} + +function safeExternalHref(value: string | null | undefined): string | null { + if (!value || value.trim().length === 0) return null; + + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:") return null; + + const origin = TRUSTED_EXTERNAL_ORIGINS[url.hostname.toLowerCase()]; + if (!origin) return null; + + const safePath = url.pathname.split("/").map(encodePathSegment).join("/"); + const safeSearch = new URLSearchParams(url.searchParams).toString(); + return `${origin}${safePath}${safeSearch ? `?${safeSearch}` : ""}`; + } catch { + return null; + } +} + +function encodePathSegment(segment: string): string { + try { + return encodeURIComponent(decodeURIComponent(segment)); + } catch { + return encodeURIComponent(segment); + } +} + +function shouldHideRedundantAction( + action: { label: string; url: string }, + links: { prUrl: string | null; reviewUrl: string | null }, +): boolean { + const label = normalizeActionText(action.label); + const actionUrl = canonicalUrl(action.url); + const prUrl = canonicalUrl(links.prUrl); + const reviewUrl = canonicalUrl(links.reviewUrl); + + if (label.includes("dashboard")) return true; + if (prUrl && actionUrl === prUrl) return true; + if (reviewUrl && actionUrl === reviewUrl) return true; + + return false; +} + +function getEscalationCause(notification: DashboardNotificationRecord): string | null { + const data = notificationDataV3(notification); + if (!data) return null; + const escalation = recordField(data, "escalation"); + return escalation ? stringField(escalation, "cause") : null; +} + +function priorityClass(priority: string): string { + if (priority === "urgent") return "dashboard-notification-item--urgent"; + if (priority === "action") return "dashboard-notification-item--action"; + if (priority === "warning") return "dashboard-notification-item--warning"; + return "dashboard-notification-item--info"; +} + +function normalizeEventText(value: string): string { + return value.toLowerCase().replace(/[_\s]+/g, "-"); +} + +function successNotificationLabel(notification: DashboardNotificationRecord): string | null { + const data = notificationDataV3(notification); + if (!data) return null; + + const semanticType = stringField(data, "semanticType"); + if (semanticType && normalizeEventText(semanticType) === "summary.all-complete") { + return "all complete"; + } + + const merge = recordField(data, "merge"); + const review = recordField(data, "review"); + const mergeReady = merge ? booleanField(merge, "ready") : null; + const reviewDecision = review ? stringField(review, "decision") : null; + + if ( + semanticType === "merge.ready" || + semanticType === "review.approved" || + mergeReady === true || + reviewDecision === "approved" + ) { + return "approved"; + } + + return null; +} + +function notificationToneClass(notification: DashboardNotificationRecord): string { + return successNotificationLabel(notification) + ? "dashboard-notification-item--success" + : priorityClass(notification.event.priority); +} + +function notificationKey(notification: DashboardNotificationRecord): string { + return `${notification.id}:${notification.receivedAt}`; +} + +function readStoredReadIds(): Set { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(READ_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((value): value is string => typeof value === "string")); + } catch { + return new Set(); + } +} + +function writeStoredReadIds(readIds: Set): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(READ_STORAGE_KEY, JSON.stringify([...readIds])); + } catch { + // Read state is a UI convenience. Storage failures should not break the panel. + } +} + +function NotificationItem({ + isRead, + notification, + onMarkRead, + onMarkUnread, +}: { + isRead: boolean; + notification: DashboardNotificationRecord; + onMarkRead: () => void; + onMarkUnread: () => void; +}) { + const { event } = notification; + const sessionHref = projectSessionPath(event.projectId, event.sessionId); + const prUrl = safeExternalHref(getPRUrl(notification)); + const reviewUrl = safeExternalHref(getReviewUrl(notification)); + const escalationCause = getEscalationCause(notification); + const successLabel = successNotificationLabel(notification); + const label = successLabel ?? event.priority; + const urlActions = (notification.actions ?? []) + .map((action) => { + const safeUrl = safeExternalHref(action.url); + return safeUrl ? { label: action.label, url: safeUrl } : null; + }) + .filter((action): action is { label: string; url: string } => action !== null) + .filter((action) => !shouldHideRedundantAction(action, { prUrl, reviewUrl })); + + return ( +
  • +
  • + ); +} + +function pruneReadIds( + readIds: Set, + notifications: DashboardNotificationRecord[], +): Set { + const available = new Set(notifications.map(notificationKey)); + return new Set([...readIds].filter((id) => available.has(id))); +} + +export function DashboardNotificationButton() { + const mux = useMuxOptional(); + const notifications = mux?.notifications ?? []; + const error = mux?.notificationError ?? null; + const [open, setOpen] = useState(false); + const [view, setView] = useState("all"); + const [readIds, setReadIds] = useState>(() => readStoredReadIds()); + const rootRef = useRef(null); + const unreadCount = useMemo( + () => + notifications.filter((notification) => !readIds.has(notificationKey(notification))).length, + [notifications, readIds], + ); + const allRead = notifications.length > 0 && unreadCount === 0; + const visibleNotifications = useMemo(() => { + const filtered = + view === "unread" + ? notifications.filter((notification) => !readIds.has(notificationKey(notification))) + : notifications; + return [...filtered].reverse(); + }, [notifications, readIds, view]); + + useEffect(() => { + if (notifications.length === 0) return; + setReadIds((current) => { + const pruned = pruneReadIds(current, notifications); + if (pruned.size === current.size) return current; + writeStoredReadIds(pruned); + return pruned; + }); + }, [notifications]); + + const markRead = (notification: DashboardNotificationRecord) => { + setReadIds((current) => { + const key = notificationKey(notification); + if (current.has(key)) return current; + const next = new Set(current); + next.add(key); + writeStoredReadIds(next); + return next; + }); + }; + + const markUnread = (notification: DashboardNotificationRecord) => { + setReadIds((current) => { + const key = notificationKey(notification); + if (!current.has(key)) return current; + const next = new Set(current); + next.delete(key); + writeStoredReadIds(next); + return next; + }); + }; + + const markAllRead = () => { + setReadIds((current) => { + const next = new Set(current); + for (const notification of notifications) { + next.add(notificationKey(notification)); + } + writeStoredReadIds(next); + return next; + }); + }; + + const markAllUnread = () => { + setReadIds((current) => { + const next = new Set(current); + for (const notification of notifications) { + next.delete(notificationKey(notification)); + } + writeStoredReadIds(next); + return next; + }); + }; + + useEffect(() => { + if (!open) return; + + const onPointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + return ( +
    + + + {open ? ( +
    +
    +
    Notifications
    +
    + +
    +
    +
    + + +
    + {error ?
    {error}
    : null} + {visibleNotifications.length > 0 ? ( +
      + {visibleNotifications.map((notification) => ( + markRead(notification)} + onMarkUnread={() => markUnread(notification)} + /> + ))} +
    + ) : ( +
    + {view === "unread" ? "No unread notifications" : "No notifications yet"} +
    + )} +
    + ) : null} +
    + ); +} diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index 25432df01..b264632d4 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -3,12 +3,9 @@ import { useEffect, useRef, useState } from "react"; import { CI_STATUS } from "@aoagents/ao-core/types"; import { cn } from "@/lib/cn"; -import { - type DashboardSession, - type DashboardPR, - isPRMergeReady, -} from "@/lib/types"; +import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types"; import type { ProjectInfo } from "@/lib/project-name"; +import { DashboardNotificationButton } from "./DashboardNotificationButton"; import { SessionDetailPRCard } from "./SessionDetailPRCard"; import { askAgentToFix } from "./session-detail-agent-actions"; import { buildGitHubBranchUrl } from "./session-detail-utils"; @@ -108,8 +105,7 @@ export function SessionDetailHeader({ const headerProjectLabel = projects.find((project) => project.id === session.projectId)?.name ?? session.projectId; - const showHeaderProjectLabel = - headerProjectLabel.trim().toLowerCase() !== "agent orchestrator"; + const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator"; return (
    @@ -159,12 +155,8 @@ export function SessionDetailHeader({ {showHeaderProjectLabel && ( {headerProjectLabel} )} - - {session.id} - - {isOrchestrator && ( - orchestrator - )} + {session.id} + {isOrchestrator && orchestrator}
    - + {activity.label}
    {session.branch ? ( @@ -199,14 +188,11 @@ export function SessionDetailHeader({